home_view.dart 27 KB

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