home_view.dart 27 KB

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