home_view.dart 27 KB

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