home_view.dart 26 KB

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