home_view.dart 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  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_theme_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. ).withClickCursor(
  139. isDesktop,
  140. );
  141. },
  142. );
  143. }).toList(),
  144. ),
  145. if (controller.bannerList.length > 1)
  146. Positioned(
  147. bottom: 0,
  148. left: 0,
  149. right: 0,
  150. child: Row(
  151. mainAxisAlignment:
  152. MainAxisAlignment.center,
  153. children: controller.bannerList
  154. .asMap()
  155. .entries
  156. .map((entry) {
  157. return AnimatedContainer(
  158. duration:
  159. const Duration(
  160. milliseconds: 300,
  161. ),
  162. curve: Curves.easeInOut,
  163. width:
  164. controller
  165. .currentBannerIndex ==
  166. entry.key
  167. ? 16
  168. : 6,
  169. height: 6,
  170. margin:
  171. const EdgeInsets.symmetric(
  172. vertical: 8.0,
  173. horizontal: 2.0,
  174. ),
  175. decoration: BoxDecoration(
  176. borderRadius:
  177. BorderRadius.circular(
  178. 6,
  179. ),
  180. color: Colors.white
  181. .withValues(
  182. alpha:
  183. controller
  184. .currentBannerIndex ==
  185. entry
  186. .key
  187. ? 0.9
  188. : 0.4,
  189. ),
  190. ),
  191. );
  192. })
  193. .toList(),
  194. ),
  195. ),
  196. ],
  197. );
  198. }),
  199. ),
  200. MenuList(),
  201. 14.verticalSpaceFromWidth,
  202. ],
  203. ),
  204. ),
  205. ),
  206. ],
  207. ),
  208. ),
  209. );
  210. },
  211. ),
  212. ),
  213. ],
  214. ),
  215. ),
  216. );
  217. }
  218. Widget _buildAppBar() {
  219. return Row(
  220. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  221. children: [
  222. Obx(() {
  223. final bgColor = controller.apiController.userLevel == 3
  224. ? DarkThemeColors.homePremiumColor
  225. : controller.apiController.userLevel == 9999
  226. ? DarkThemeColors.homeTestColor
  227. : DarkThemeColors.homeFreeColor;
  228. return ClipRRect(
  229. borderRadius: BorderRadius.circular(100.r),
  230. child: ClickOpacity(
  231. onTap: () => Get.toNamed(Routes.SUBSCRIPTION),
  232. child: Container(
  233. decoration: BoxDecoration(
  234. color: bgColor.withValues(alpha: 0.05),
  235. ),
  236. child: Stack(
  237. children: [
  238. // 左上角光晕
  239. Positioned(
  240. left: -8.w,
  241. top: -16.w,
  242. child: Container(
  243. width: 32.w,
  244. height: 32.w,
  245. decoration: BoxDecoration(
  246. shape: BoxShape.circle,
  247. gradient: RadialGradient(
  248. colors: [
  249. bgColor.withValues(alpha: 0.85),
  250. bgColor.withValues(alpha: 0.05),
  251. ],
  252. stops: const [0.0, 1.0],
  253. ),
  254. ),
  255. ),
  256. ),
  257. // 右下角光晕
  258. Positioned(
  259. right: 12.w,
  260. bottom: -12.w,
  261. child: ImageFiltered(
  262. imageFilter: ImageFilter.blur(
  263. sigmaX: 8,
  264. sigmaY: 8,
  265. tileMode: TileMode.decal,
  266. ),
  267. child: Container(
  268. width: 42.w,
  269. height: 16.w,
  270. decoration: BoxDecoration(
  271. borderRadius: BorderRadius.circular(8.w),
  272. color: bgColor.withValues(alpha: 0.85),
  273. ),
  274. ),
  275. ),
  276. ),
  277. Padding(
  278. padding: EdgeInsets.symmetric(
  279. horizontal: 6.w,
  280. vertical: 4.w,
  281. ),
  282. child: Row(
  283. children: [
  284. Obx(
  285. () => IXImage(
  286. source: controller.apiController.userLevel == 3
  287. ? Assets.homePremium
  288. : controller.apiController.userLevel == 9999
  289. ? Assets.homeTest
  290. : Assets.homeFree,
  291. width: controller.apiController.userLevel == 3
  292. ? 92.w
  293. : 64.w,
  294. height: 28.w,
  295. sourceType: ImageSourceType.asset,
  296. ),
  297. ),
  298. _buildReminder(),
  299. ],
  300. ),
  301. ),
  302. // 倒计时提醒
  303. ],
  304. ),
  305. ),
  306. ),
  307. );
  308. }),
  309. ClickOpacity(
  310. child: Padding(
  311. padding: EdgeInsets.only(
  312. left: 10.w,
  313. right: 0.w,
  314. top: 10.w,
  315. bottom: 10.w,
  316. ),
  317. child: Icon(
  318. IconFont.icon09,
  319. size: 26.w,
  320. color: Get.reactiveTheme.hintColor,
  321. ),
  322. ),
  323. onTap: () {
  324. controller.collapseRecentLocations();
  325. Get.toNamed(Routes.SETTING);
  326. },
  327. ),
  328. ],
  329. );
  330. }
  331. Widget _buildReminder() {
  332. // return Obx(
  333. // () => Text(
  334. // controller.apiController.isGuest &&
  335. // !controller.apiController.isPremium &&
  336. // controller.apiController.remainTimeSeconds > 0
  337. // ? controller.apiController.remainTimeFormatted
  338. // : controller.coreController.timer,
  339. // style: TextStyle(
  340. // fontSize: 13.sp,
  341. // height: 1.5,
  342. // fontStyle: FontStyle.italic,
  343. // fontWeight: FontWeight.w500,
  344. // fontFeatures: [FontFeature.tabularFigures()],
  345. // color:
  346. // controller.apiController.userLevel == 3 ||
  347. // controller.apiController.userLevel == 9999
  348. // ? Get.reactiveTheme.textTheme.bodyLarge!.color
  349. // : Get.reactiveTheme.hintColor,
  350. // ),
  351. // ),
  352. // );
  353. return Obx(() {
  354. // 只监听 shouldShowCountdown 和 remainTimeFormatted
  355. // 只有文案或显示状态变化时才更新 UI
  356. if (!controller.apiController.shouldShowCountdown) {
  357. return const SizedBox.shrink();
  358. }
  359. return Text(
  360. '${controller.apiController.remainTimeFormatted} ',
  361. style: TextStyle(
  362. fontSize: 13.sp,
  363. height: 1.5,
  364. fontStyle: FontStyle.italic,
  365. fontWeight: FontWeight.w500,
  366. fontFeatures: [FontFeature.tabularFigures()],
  367. color:
  368. controller.apiController.userLevel == 3 ||
  369. controller.apiController.userLevel == 9999
  370. ? Get.reactiveTheme.textTheme.bodyLarge!.color
  371. : Get.reactiveTheme.hintColor,
  372. ),
  373. );
  374. });
  375. }
  376. Widget _buildConnectionButton() {
  377. return Obx(
  378. () => ConnectionThemeButton(
  379. state: controller.coreController.state,
  380. onTap: () {
  381. controller.collapseRecentLocations();
  382. controller.setDefaultAutoConnect();
  383. },
  384. ).withClickCursor(isDesktop),
  385. );
  386. }
  387. /// 构建位置堆叠效果(选中位置 + 最近位置)
  388. Widget _buildLocationStack() {
  389. return Obx(() {
  390. if (controller.selectedLocation == null) {
  391. return const SizedBox.shrink();
  392. }
  393. return Stack(
  394. children: [
  395. // 最近位置列表(背景层)
  396. if (controller.recentLocations.isNotEmpty)
  397. _buildRecentLocationsCard(),
  398. // 选中位置(前景层)
  399. _buildSelectedLocationCard(),
  400. ],
  401. );
  402. });
  403. }
  404. /// 构建选中位置卡片
  405. Widget _buildSelectedLocationCard() {
  406. return GestureDetector(
  407. onTap: () {
  408. controller.collapseRecentLocations();
  409. Get.toNamed(Routes.NODE);
  410. },
  411. child: Obx(() {
  412. final isLight = ReactiveTheme.isLightTheme;
  413. return Container(
  414. height: 56.w,
  415. width: double.maxFinite,
  416. padding: EdgeInsets.only(left: 16.w, right: 10.w),
  417. decoration: BoxDecoration(
  418. color: Get.reactiveTheme.highlightColor,
  419. borderRadius: BorderRadius.circular(12.r),
  420. boxShadow: isLight
  421. ? [
  422. BoxShadow(
  423. color: Colors.black.withOpacity(0.06),
  424. offset: const Offset(0, 4),
  425. blurRadius: 16,
  426. spreadRadius: 0,
  427. ),
  428. ]
  429. : null,
  430. ),
  431. child: Row(
  432. children: [
  433. // 国旗图标
  434. CountryIcon(
  435. countryCode: controller.selectedLocation?.country ?? '',
  436. width: 32.w,
  437. height: 24.w,
  438. borderRadius: 4.r,
  439. ),
  440. 10.horizontalSpace,
  441. // 位置名称
  442. Expanded(
  443. child: Text(
  444. '${controller.selectedLocation?.code ?? ''} - ${controller.selectedLocation?.name ?? ''}',
  445. style: TextStyle(
  446. fontSize: 16.sp,
  447. height: 1.5,
  448. fontWeight: FontWeight.w500,
  449. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  450. ),
  451. ),
  452. ),
  453. // 箭头图标
  454. Icon(
  455. IconFont.icon02,
  456. size: 20.w,
  457. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  458. ),
  459. ],
  460. ),
  461. ).withClickCursor(isDesktop);
  462. }),
  463. );
  464. }
  465. /// 构建最近位置卡片(支持展开/收缩)
  466. Widget _buildRecentLocationsCard() {
  467. return Obx(() {
  468. final isLight = ReactiveTheme.isLightTheme;
  469. return Container(
  470. margin: EdgeInsets.symmetric(horizontal: 10.w),
  471. padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 56.w, bottom: 0),
  472. decoration: BoxDecoration(
  473. color: Get.reactiveTheme.cardColor,
  474. borderRadius: BorderRadius.circular(12.r),
  475. border: isLight
  476. ? Border.all(color: Get.reactiveTheme.dividerColor, width: 1.w)
  477. : null,
  478. ),
  479. child: Column(
  480. children: [
  481. GestureDetector(
  482. behavior: HitTestBehavior.opaque,
  483. onTap: () {
  484. controller.isRecentLocationsExpanded =
  485. !controller.isRecentLocationsExpanded;
  486. },
  487. child: SizedBox(
  488. height: 44.w,
  489. child: Row(
  490. children: [
  491. Icon(
  492. IconFont.icon68,
  493. size: 16.w,
  494. color: Get.reactiveTheme.hintColor,
  495. ),
  496. SizedBox(width: 4.w),
  497. Text(
  498. Strings.recent.tr,
  499. style: TextStyle(
  500. fontSize: 12.sp,
  501. height: 1.2,
  502. color: Get.reactiveTheme.hintColor,
  503. ),
  504. ),
  505. const Spacer(),
  506. // 最近三个节点的国旗图标(收缩状态)或箭头(展开状态)
  507. Obx(() {
  508. return AnimatedOpacity(
  509. opacity: controller.isRecentLocationsExpanded
  510. ? 0.0
  511. : 1.0,
  512. duration: const Duration(milliseconds: 300),
  513. child: IgnorePointer(
  514. ignoring: controller.isRecentLocationsExpanded,
  515. child: Row(
  516. mainAxisSize: MainAxisSize.min,
  517. children: [
  518. ...controller.recentLocations.take(3).map((
  519. location,
  520. ) {
  521. return Container(
  522. margin: EdgeInsets.only(right: 4.w),
  523. decoration: BoxDecoration(
  524. borderRadius: BorderRadius.circular(5.r),
  525. border: Border.all(
  526. color: Get.reactiveTheme.canvasColor,
  527. width: 0.4.w,
  528. ),
  529. ),
  530. child: CountryIcon(
  531. countryCode: location.country ?? '',
  532. width: 24.w,
  533. height: 16.w,
  534. borderRadius: 4.r,
  535. ),
  536. );
  537. }),
  538. ],
  539. ),
  540. ),
  541. );
  542. }),
  543. Obx(() {
  544. return AnimatedRotation(
  545. turns: controller.isRecentLocationsExpanded
  546. ? 0.25
  547. : 0.0,
  548. duration: const Duration(milliseconds: 300),
  549. child: Icon(
  550. IconFont.icon02,
  551. size: 20.w,
  552. color: Get.reactiveTheme.hintColor,
  553. ),
  554. );
  555. }),
  556. ],
  557. ),
  558. ),
  559. ),
  560. // 最近位置列表(可折叠)
  561. Obx(() {
  562. return ClipRect(
  563. child: AnimatedAlign(
  564. duration: const Duration(milliseconds: 300),
  565. curve: Curves.easeInOut,
  566. heightFactor: controller.isRecentLocationsExpanded
  567. ? 1.0
  568. : 0.0,
  569. alignment: Alignment.topLeft,
  570. child: AnimatedOpacity(
  571. opacity: controller.isRecentLocationsExpanded ? 1.0 : 0.0,
  572. duration: const Duration(milliseconds: 300),
  573. child: Column(
  574. children: controller.recentLocations.map((location) {
  575. return ClickOpacity(
  576. onTap: () {
  577. controller.isRecentLocationsExpanded =
  578. !controller.isRecentLocationsExpanded;
  579. controller.selectLocation(location);
  580. controller.handleConnect();
  581. },
  582. child: Column(
  583. children: [
  584. Divider(
  585. height: 1,
  586. color: Get.reactiveTheme.dividerColor,
  587. ),
  588. Container(
  589. margin: EdgeInsets.symmetric(vertical: 12.h),
  590. child: Row(
  591. mainAxisAlignment: MainAxisAlignment.center,
  592. crossAxisAlignment: CrossAxisAlignment.center,
  593. children: [
  594. // 国旗图标
  595. CountryIcon(
  596. countryCode: location.country ?? '',
  597. width: 28.w,
  598. height: 21.w,
  599. borderRadius: 4.r,
  600. ),
  601. SizedBox(width: 10.w),
  602. // 位置信息
  603. Expanded(
  604. child: Text(
  605. '${location.code} - ${location.name}',
  606. style: TextStyle(
  607. fontSize: 14.sp,
  608. fontWeight: FontWeight.w500,
  609. color: Get.reactiveTheme.hintColor,
  610. ),
  611. ),
  612. ),
  613. ],
  614. ),
  615. ),
  616. ],
  617. ),
  618. );
  619. }).toList(),
  620. ),
  621. ),
  622. ),
  623. );
  624. }),
  625. ],
  626. ),
  627. ).withClickCursor(isDesktop);
  628. });
  629. }
  630. }