home_view.dart 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. import 'dart:ui';
  2. import 'package:carousel_slider/carousel_slider.dart';
  3. import 'package:flutter/material.dart' hide ConnectionState;
  4. import 'package:flutter_screenutil/flutter_screenutil.dart';
  5. import 'package:get/get.dart';
  6. import 'package:nomo/app/constants/iconfont/iconfont.dart';
  7. import 'package:nomo/app/extensions/widget_extension.dart';
  8. import 'package:nomo/utils/misc.dart';
  9. import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
  10. import '../../../../config/theme/dark_theme_colors.dart';
  11. import '../../../../config/theme/theme_extensions/theme_extension.dart';
  12. import '../../../../config/translations/strings_enum.dart';
  13. import '../../../base/base_view.dart';
  14. import '../../../constants/assets.dart';
  15. import '../../../routes/app_pages.dart';
  16. import '../../../widgets/click_opacity.dart';
  17. import '../../../widgets/country_icon.dart';
  18. import '../../../widgets/ix_image.dart';
  19. import '../controllers/home_controller.dart';
  20. import '../widgets/connection_round_button.dart';
  21. import '../widgets/menu_list.dart';
  22. class HomeView extends BaseView<HomeController> {
  23. const HomeView({super.key});
  24. @override
  25. bool get isPopScope => true;
  26. @override
  27. Widget buildContent(BuildContext context) {
  28. return _buildCustomScrollView();
  29. }
  30. Widget _buildCustomScrollView() {
  31. return SafeArea(
  32. child: Padding(
  33. padding: isDesktop
  34. ? EdgeInsets.all(14.w)
  35. : EdgeInsets.symmetric(horizontal: 14.w),
  36. child: Column(
  37. children: [
  38. _buildAppBar(),
  39. 20.verticalSpaceFromWidth,
  40. Expanded(
  41. child: LayoutBuilder(
  42. builder: (context, constraints) {
  43. // 确保 viewportHeight 不会为负数(键盘弹出时)
  44. final double viewportHeight = (constraints.maxHeight - 388.w)
  45. .clamp(0.0, double.infinity);
  46. return GestureDetector(
  47. behavior: HitTestBehavior.translucent,
  48. onTap: () {
  49. controller.collapseRecentLocations();
  50. },
  51. child: SmartRefresher(
  52. enablePullDown: true,
  53. enablePullUp: false,
  54. controller: controller.refreshController,
  55. onRefresh: controller.onRefresh,
  56. child: CustomScrollView(
  57. slivers: [
  58. // 1. 顶部和中间部分
  59. SliverList(
  60. delegate: SliverChildListDelegate([
  61. 20.verticalSpaceFromWidth,
  62. Stack(
  63. children: [
  64. Container(
  65. alignment: Alignment.center,
  66. margin: EdgeInsets.only(top: 138.w),
  67. child: _buildConnectionButton(),
  68. ),
  69. _buildLocationStack(),
  70. ],
  71. ),
  72. 20.verticalSpaceFromWidth,
  73. // // 第二部分:中间(内容可长可短)
  74. // Container(
  75. // color: Colors.green[100],
  76. // height: 400, // 修改这个高度来测试滚动效果
  77. // child: Center(child: Text("中间内容区域")),
  78. // ),
  79. ]),
  80. ),
  81. // 2. 底部部分:关键所在
  82. SliverFillRemaining(
  83. hasScrollBody: false,
  84. child: ConstrainedBox(
  85. constraints: BoxConstraints(
  86. minHeight: viewportHeight,
  87. ),
  88. child: Column(
  89. children: [
  90. Spacer(), // 自动撑开上方空间,将底部内容挤下去
  91. Padding(
  92. padding: EdgeInsets.symmetric(
  93. vertical: 14.w,
  94. ),
  95. child: Obx(() {
  96. if (controller.bannerList.isEmpty) {
  97. return SizedBox.shrink();
  98. }
  99. return Stack(
  100. children: [
  101. CarouselSlider(
  102. options: CarouselOptions(
  103. height: 80.w,
  104. viewportFraction: 1.0,
  105. autoPlay:
  106. controller.bannerList.length >
  107. 1,
  108. autoPlayInterval: const Duration(
  109. seconds: 5,
  110. ),
  111. onPageChanged: (index, reason) {
  112. controller.currentBannerIndex =
  113. index;
  114. },
  115. ),
  116. items: controller.bannerList.map((
  117. banner,
  118. ) {
  119. return Builder(
  120. builder:
  121. (BuildContext context) {
  122. return GestureDetector(
  123. onTap: () => controller
  124. .onBannerTap(
  125. banner,
  126. ),
  127. child: IXImage(
  128. source:
  129. banner.img ?? '',
  130. width:
  131. double.infinity,
  132. height: 80.w,
  133. sourceType:
  134. ImageSourceType
  135. .network,
  136. borderRadius: 14.r,
  137. ),
  138. );
  139. },
  140. );
  141. }).toList(),
  142. ),
  143. if (controller.bannerList.length > 1)
  144. Positioned(
  145. bottom: 0,
  146. left: 0,
  147. right: 0,
  148. child: Row(
  149. mainAxisAlignment:
  150. MainAxisAlignment.center,
  151. children: controller.bannerList
  152. .asMap()
  153. .entries
  154. .map((entry) {
  155. return AnimatedContainer(
  156. duration:
  157. const Duration(
  158. milliseconds: 300,
  159. ),
  160. curve: Curves.easeInOut,
  161. width:
  162. controller
  163. .currentBannerIndex ==
  164. entry.key
  165. ? 16
  166. : 6,
  167. height: 6,
  168. margin:
  169. const EdgeInsets.symmetric(
  170. vertical: 8.0,
  171. horizontal: 2.0,
  172. ),
  173. decoration: BoxDecoration(
  174. borderRadius:
  175. BorderRadius.circular(
  176. 6,
  177. ),
  178. color: Colors.white
  179. .withValues(
  180. alpha:
  181. controller
  182. .currentBannerIndex ==
  183. entry
  184. .key
  185. ? 0.9
  186. : 0.4,
  187. ),
  188. ),
  189. );
  190. })
  191. .toList(),
  192. ),
  193. ),
  194. ],
  195. );
  196. }),
  197. ),
  198. MenuList(),
  199. ],
  200. ),
  201. ),
  202. ),
  203. ],
  204. ),
  205. ),
  206. );
  207. },
  208. ),
  209. ),
  210. ],
  211. ),
  212. ),
  213. );
  214. }
  215. Widget _buildAppBar() {
  216. return Row(
  217. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  218. children: [
  219. Obx(() {
  220. final bgColor = controller.apiController.userLevel == 3
  221. ? DarkThemeColors.homePremiumColor
  222. : controller.apiController.userLevel == 9999
  223. ? DarkThemeColors.homeTestColor
  224. : DarkThemeColors.homeFreeColor;
  225. return ClipRRect(
  226. borderRadius: BorderRadius.circular(100.r),
  227. child: ClickOpacity(
  228. onTap: () => Get.toNamed(Routes.SUBSCRIPTION),
  229. child: Container(
  230. decoration: BoxDecoration(
  231. color: bgColor.withValues(alpha: 0.05),
  232. ),
  233. child: Stack(
  234. children: [
  235. // 左上角光晕
  236. Positioned(
  237. left: -8.w,
  238. top: -16.w,
  239. child: Container(
  240. width: 32.w,
  241. height: 32.w,
  242. decoration: BoxDecoration(
  243. shape: BoxShape.circle,
  244. gradient: RadialGradient(
  245. colors: [
  246. bgColor.withValues(alpha: 0.85),
  247. bgColor.withValues(alpha: 0.05),
  248. ],
  249. stops: const [0.0, 1.0],
  250. ),
  251. ),
  252. ),
  253. ),
  254. // 右下角光晕
  255. Positioned(
  256. right: 12.w,
  257. bottom: -12.w,
  258. child: ImageFiltered(
  259. imageFilter: ImageFilter.blur(
  260. sigmaX: 8,
  261. sigmaY: 8,
  262. tileMode: TileMode.decal,
  263. ),
  264. child: Container(
  265. width: 42.w,
  266. height: 16.w,
  267. decoration: BoxDecoration(
  268. borderRadius: BorderRadius.circular(8.w),
  269. color: bgColor.withValues(alpha: 0.85),
  270. ),
  271. ),
  272. ),
  273. ),
  274. Padding(
  275. padding: EdgeInsets.symmetric(
  276. horizontal: 6.w,
  277. vertical: 4.w,
  278. ),
  279. child: Row(
  280. children: [
  281. Obx(
  282. () => IXImage(
  283. source: controller.apiController.userLevel == 3
  284. ? Assets.homePremium
  285. : controller.apiController.userLevel == 9999
  286. ? Assets.homeTest
  287. : Assets.homeFree,
  288. width: controller.apiController.userLevel == 3
  289. ? 92.w
  290. : 64.w,
  291. height: 28.w,
  292. sourceType: ImageSourceType.asset,
  293. ),
  294. ),
  295. _buildReminder(),
  296. ],
  297. ),
  298. ),
  299. // 倒计时提醒
  300. ],
  301. ),
  302. ),
  303. ),
  304. );
  305. }),
  306. ClickOpacity(
  307. child: Padding(
  308. padding: EdgeInsets.only(
  309. left: 10.w,
  310. right: 0.w,
  311. top: 10.w,
  312. bottom: 10.w,
  313. ),
  314. child: Icon(
  315. IconFont.icon09,
  316. size: 26.w,
  317. color: Get.reactiveTheme.hintColor,
  318. ),
  319. ),
  320. onTap: () {
  321. controller.collapseRecentLocations();
  322. Get.toNamed(Routes.SETTING);
  323. },
  324. ),
  325. ],
  326. );
  327. }
  328. Widget _buildReminder() {
  329. // return Obx(
  330. // () => Text(
  331. // controller.apiController.isGuest &&
  332. // !controller.apiController.isPremium &&
  333. // controller.apiController.remainTimeSeconds > 0
  334. // ? controller.apiController.remainTimeFormatted
  335. // : controller.coreController.timer,
  336. // style: TextStyle(
  337. // fontSize: 13.sp,
  338. // height: 1.5,
  339. // fontStyle: FontStyle.italic,
  340. // fontWeight: FontWeight.w500,
  341. // fontFeatures: [FontFeature.tabularFigures()],
  342. // color:
  343. // controller.apiController.userLevel == 3 ||
  344. // controller.apiController.userLevel == 9999
  345. // ? Get.reactiveTheme.textTheme.bodyLarge!.color
  346. // : Get.reactiveTheme.hintColor,
  347. // ),
  348. // ),
  349. // );
  350. return Obx(() {
  351. // 只监听 shouldShowCountdown 和 remainTimeFormatted
  352. // 只有文案或显示状态变化时才更新 UI
  353. if (!controller.apiController.shouldShowCountdown) {
  354. return const SizedBox.shrink();
  355. }
  356. return Text(
  357. '${controller.apiController.remainTimeFormatted} ',
  358. style: TextStyle(
  359. fontSize: 13.sp,
  360. height: 1.5,
  361. fontStyle: FontStyle.italic,
  362. fontWeight: FontWeight.w500,
  363. fontFeatures: [FontFeature.tabularFigures()],
  364. color:
  365. controller.apiController.userLevel == 3 ||
  366. controller.apiController.userLevel == 9999
  367. ? Get.reactiveTheme.textTheme.bodyLarge!.color
  368. : Get.reactiveTheme.hintColor,
  369. ),
  370. );
  371. });
  372. }
  373. Widget _buildConnectionButton() {
  374. return Obx(
  375. () => ConnectionRoundButton(
  376. state: controller.coreController.state,
  377. onTap: () {
  378. controller.collapseRecentLocations();
  379. controller.setDefaultAutoConnect();
  380. },
  381. ).withClickCursor(isDesktop),
  382. );
  383. }
  384. /// 构建位置堆叠效果(选中位置 + 最近位置)
  385. Widget _buildLocationStack() {
  386. return Obx(() {
  387. if (controller.selectedLocation == null) {
  388. return const SizedBox.shrink();
  389. }
  390. return Stack(
  391. children: [
  392. // 最近位置列表(背景层)
  393. if (controller.recentLocations.isNotEmpty)
  394. _buildRecentLocationsCard(),
  395. // 选中位置(前景层)
  396. _buildSelectedLocationCard(),
  397. ],
  398. );
  399. });
  400. }
  401. /// 构建选中位置卡片
  402. Widget _buildSelectedLocationCard() {
  403. return GestureDetector(
  404. onTap: () {
  405. controller.collapseRecentLocations();
  406. Get.toNamed(Routes.NODE);
  407. },
  408. child: Obx(() {
  409. return Container(
  410. height: 56.w,
  411. width: double.maxFinite,
  412. padding: EdgeInsets.only(left: 16.w, right: 10.w),
  413. decoration: BoxDecoration(
  414. color: Get.reactiveTheme.highlightColor,
  415. borderRadius: BorderRadius.circular(12.r),
  416. ),
  417. child: Row(
  418. children: [
  419. // 国旗图标
  420. CountryIcon(
  421. countryCode: controller.selectedLocation?.country ?? '',
  422. width: 32.w,
  423. height: 24.w,
  424. borderRadius: 4.r,
  425. ),
  426. 10.horizontalSpace,
  427. // 位置名称
  428. Expanded(
  429. child: Text(
  430. '${controller.selectedLocation?.code ?? ''} - ${controller.selectedLocation?.name ?? ''}',
  431. style: TextStyle(
  432. fontSize: 16.sp,
  433. height: 1.5,
  434. fontWeight: FontWeight.w500,
  435. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  436. ),
  437. ),
  438. ),
  439. // 箭头图标
  440. Icon(
  441. IconFont.icon02,
  442. size: 20.w,
  443. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  444. ),
  445. ],
  446. ),
  447. ).withClickCursor(isDesktop);
  448. }),
  449. );
  450. }
  451. /// 构建最近位置卡片(支持展开/收缩)
  452. Widget _buildRecentLocationsCard() {
  453. return Obx(() {
  454. return Container(
  455. margin: EdgeInsets.symmetric(horizontal: 10.w),
  456. padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 56.w, bottom: 0),
  457. decoration: BoxDecoration(
  458. color: Get.reactiveTheme.cardColor,
  459. borderRadius: BorderRadius.circular(12.r),
  460. ),
  461. child: Column(
  462. children: [
  463. GestureDetector(
  464. behavior: HitTestBehavior.opaque,
  465. onTap: () {
  466. controller.isRecentLocationsExpanded =
  467. !controller.isRecentLocationsExpanded;
  468. },
  469. child: SizedBox(
  470. height: 44.w,
  471. child: Row(
  472. children: [
  473. Icon(
  474. IconFont.icon68,
  475. size: 16.w,
  476. color: Get.reactiveTheme.hintColor,
  477. ),
  478. SizedBox(width: 4.w),
  479. Text(
  480. Strings.recent.tr,
  481. style: TextStyle(
  482. fontSize: 12.sp,
  483. height: 1.2,
  484. color: Get.reactiveTheme.hintColor,
  485. ),
  486. ),
  487. const Spacer(),
  488. // 最近三个节点的国旗图标(收缩状态)或箭头(展开状态)
  489. Obx(() {
  490. return AnimatedOpacity(
  491. opacity: controller.isRecentLocationsExpanded
  492. ? 0.0
  493. : 1.0,
  494. duration: const Duration(milliseconds: 300),
  495. child: IgnorePointer(
  496. ignoring: controller.isRecentLocationsExpanded,
  497. child: Row(
  498. mainAxisSize: MainAxisSize.min,
  499. children: [
  500. ...controller.recentLocations.take(3).map((
  501. location,
  502. ) {
  503. return Container(
  504. margin: EdgeInsets.only(right: 4.w),
  505. decoration: BoxDecoration(
  506. borderRadius: BorderRadius.circular(5.r),
  507. border: Border.all(
  508. color: Get.reactiveTheme.canvasColor,
  509. width: 0.4.w,
  510. ),
  511. ),
  512. child: CountryIcon(
  513. countryCode: location.country ?? '',
  514. width: 24.w,
  515. height: 16.w,
  516. borderRadius: 4.r,
  517. ),
  518. );
  519. }),
  520. ],
  521. ),
  522. ),
  523. );
  524. }),
  525. Obx(() {
  526. return AnimatedRotation(
  527. turns: controller.isRecentLocationsExpanded
  528. ? 0.25
  529. : 0.0,
  530. duration: const Duration(milliseconds: 300),
  531. child: Icon(
  532. IconFont.icon02,
  533. size: 20.w,
  534. color: Get.reactiveTheme.hintColor,
  535. ),
  536. );
  537. }),
  538. ],
  539. ),
  540. ),
  541. ),
  542. // 最近位置列表(可折叠)
  543. Obx(() {
  544. return ClipRect(
  545. child: AnimatedAlign(
  546. duration: const Duration(milliseconds: 300),
  547. curve: Curves.easeInOut,
  548. heightFactor: controller.isRecentLocationsExpanded
  549. ? 1.0
  550. : 0.0,
  551. alignment: Alignment.topLeft,
  552. child: AnimatedOpacity(
  553. opacity: controller.isRecentLocationsExpanded ? 1.0 : 0.0,
  554. duration: const Duration(milliseconds: 300),
  555. child: Column(
  556. children: controller.recentLocations.map((location) {
  557. return ClickOpacity(
  558. onTap: () {
  559. controller.isRecentLocationsExpanded =
  560. !controller.isRecentLocationsExpanded;
  561. controller.selectLocation(location);
  562. controller.handleConnect();
  563. },
  564. child: Column(
  565. children: [
  566. Divider(
  567. height: 1,
  568. color: Get.reactiveTheme.dividerColor,
  569. ),
  570. Container(
  571. margin: EdgeInsets.symmetric(vertical: 12.h),
  572. child: Row(
  573. mainAxisAlignment: MainAxisAlignment.center,
  574. crossAxisAlignment: CrossAxisAlignment.center,
  575. children: [
  576. // 国旗图标
  577. CountryIcon(
  578. countryCode: location.country ?? '',
  579. width: 28.w,
  580. height: 21.w,
  581. borderRadius: 4.r,
  582. ),
  583. SizedBox(width: 10.w),
  584. // 位置信息
  585. Expanded(
  586. child: Text(
  587. '${location.code} - ${location.name}',
  588. style: TextStyle(
  589. fontSize: 14.sp,
  590. fontWeight: FontWeight.w500,
  591. color: Get.reactiveTheme.hintColor,
  592. ),
  593. ),
  594. ),
  595. ],
  596. ),
  597. ),
  598. ],
  599. ),
  600. );
  601. }).toList(),
  602. ),
  603. ),
  604. ),
  605. );
  606. }),
  607. ],
  608. ),
  609. ).withClickCursor(isDesktop);
  610. });
  611. }
  612. }