home_view.dart 26 KB

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