home_view.dart 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. import 'package:carousel_slider/carousel_slider.dart';
  2. import 'package:flutter/material.dart' hide ConnectionState;
  3. import 'package:flutter_screenutil/flutter_screenutil.dart';
  4. import 'package:get/get.dart';
  5. import 'package:flashlink/app/constants/iconfont/iconfont.dart';
  6. import 'package:flashlink/app/data/sp/ix_sp.dart';
  7. import 'package:flashlink/app/extensions/widget_extension.dart';
  8. import 'package:flashlink/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/light_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. // 当前宽度 * 0.212
  101. final height =
  102. MediaQuery.of(context).size.width *
  103. 0.212;
  104. return Stack(
  105. children: [
  106. CarouselSlider(
  107. options: CarouselOptions(
  108. height: height,
  109. viewportFraction: 1.0,
  110. autoPlay:
  111. controller.bannerList.length >
  112. 1,
  113. autoPlayInterval: const Duration(
  114. seconds: 5,
  115. ),
  116. onPageChanged: (index, reason) {
  117. controller.currentBannerIndex =
  118. index;
  119. },
  120. ),
  121. items: controller.bannerList.map((
  122. banner,
  123. ) {
  124. return Builder(
  125. builder:
  126. (BuildContext context) {
  127. return GestureDetector(
  128. onTap: () => controller
  129. .onBannerTap(
  130. banner,
  131. ),
  132. child: IXImage(
  133. source:
  134. banner.img ?? '',
  135. width:
  136. double.infinity,
  137. height: height,
  138. sourceType:
  139. ImageSourceType
  140. .network,
  141. borderRadius: 14.r,
  142. ),
  143. ).withClickCursor(
  144. isDesktop,
  145. );
  146. },
  147. );
  148. }).toList(),
  149. ),
  150. if (controller.bannerList.length > 1)
  151. Positioned(
  152. bottom: 0,
  153. left: 0,
  154. right: 0,
  155. child: Row(
  156. mainAxisAlignment:
  157. MainAxisAlignment.center,
  158. children: controller.bannerList
  159. .asMap()
  160. .entries
  161. .map((entry) {
  162. return AnimatedContainer(
  163. duration:
  164. const Duration(
  165. milliseconds: 300,
  166. ),
  167. curve: Curves.easeInOut,
  168. width:
  169. controller
  170. .currentBannerIndex ==
  171. entry.key
  172. ? 16
  173. : 6,
  174. height: 6,
  175. margin:
  176. const EdgeInsets.symmetric(
  177. vertical: 8.0,
  178. horizontal: 2.0,
  179. ),
  180. decoration: BoxDecoration(
  181. borderRadius:
  182. BorderRadius.circular(
  183. 6,
  184. ),
  185. color: Colors.white
  186. .withValues(
  187. alpha:
  188. controller
  189. .currentBannerIndex ==
  190. entry
  191. .key
  192. ? 0.9
  193. : 0.4,
  194. ),
  195. ),
  196. );
  197. })
  198. .toList(),
  199. ),
  200. ),
  201. ],
  202. );
  203. }),
  204. ),
  205. MenuList(),
  206. if (controller.nineBannerList.isNotEmpty)
  207. 14.verticalSpaceFromWidth,
  208. ],
  209. ),
  210. ),
  211. ),
  212. ],
  213. ),
  214. ),
  215. );
  216. },
  217. ),
  218. ),
  219. ],
  220. ),
  221. ),
  222. );
  223. }
  224. Widget _buildAppBar() {
  225. return Row(
  226. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  227. children: [
  228. Obx(() {
  229. final bgColor = controller.apiController.userLevel == 3
  230. ? ReactiveTheme.isLightTheme
  231. ? LightThemeColors.homePremiumColor
  232. : DarkThemeColors.homePremiumColor
  233. : controller.apiController.userLevel == 9999
  234. ? ReactiveTheme.isLightTheme
  235. ? LightThemeColors.homeTestColor
  236. : DarkThemeColors.homeTestColor
  237. : ReactiveTheme.isLightTheme
  238. ? LightThemeColors.homeFreeColor
  239. : DarkThemeColors.homeFreeColor;
  240. // 获取 vipRemainNotice 配置
  241. final vipRemainNotice =
  242. (IXSP.getAppConfig()?.vipRemainNotice ?? 0) * 60;
  243. final remainTimeSeconds = controller.apiController.remainTimeSeconds;
  244. // 只有当剩余时间 > 0 且 < vipRemainNotice 时才显示提醒
  245. final showReminder =
  246. remainTimeSeconds > 0 && remainTimeSeconds < vipRemainNotice;
  247. return ClickOpacity(
  248. // onTap: () => Get.toNamed(Routes.SUBSCRIPTION),
  249. child: Stack(
  250. children: [
  251. if (showReminder)
  252. Container(
  253. height: 28.w,
  254. padding: EdgeInsets.only(left: 32.w, right: 8.w),
  255. alignment: Alignment.center,
  256. margin: controller.apiController.userLevel == 3
  257. ? EdgeInsets.only(left: 64.w)
  258. : EdgeInsets.only(left: 36.w),
  259. decoration: BoxDecoration(
  260. borderRadius: BorderRadius.circular(100.r),
  261. color: bgColor,
  262. ),
  263. child: _buildReminder(),
  264. ),
  265. // Obx(
  266. // () => IXImage(
  267. // source: controller.apiController.userLevel == 3
  268. // ? controller.apiController.remainTimeSeconds > 0
  269. // ? Assets.premium
  270. // : Assets.premiumExpired
  271. // : controller.apiController.userLevel == 9999
  272. // ? Assets.test
  273. // : Assets.free,
  274. // width: controller.apiController.userLevel == 3
  275. // ? 92.w
  276. // : 92.w,
  277. // height: 28.w,
  278. // sourceType: ImageSourceType.asset,
  279. // ),
  280. // ),
  281. Row(
  282. mainAxisSize: MainAxisSize.min,
  283. children: [
  284. IXImage(
  285. source: Assets.flashlinkLogo,
  286. width: 24.w,
  287. height: 24.w,
  288. fit: BoxFit.contain,
  289. sourceType: ImageSourceType.asset,
  290. ),
  291. 4.horizontalSpace,
  292. IXImage(
  293. source: Assets.flashlink,
  294. width: 80.w,
  295. height: 20.w,
  296. fit: BoxFit.contain,
  297. sourceType: ImageSourceType.asset,
  298. ),
  299. ],
  300. ),
  301. ],
  302. ),
  303. );
  304. }),
  305. ClickOpacity(
  306. child: Padding(
  307. padding: EdgeInsets.only(
  308. left: 10.w,
  309. right: 0.w,
  310. top: 10.w,
  311. bottom: 10.w,
  312. ),
  313. child: IXImage(
  314. source: Assets.menus,
  315. width: 26.w,
  316. height: 26.w,
  317. sourceType: ImageSourceType.asset,
  318. ),
  319. ),
  320. onTap: () {
  321. controller.collapseRecentLocations();
  322. Get.toNamed(Routes.SETTING);
  323. },
  324. ),
  325. ],
  326. );
  327. }
  328. Widget _buildReminder() {
  329. // return Obx(
  330. // () => Text(
  331. // controller.apiController.isGuest &&
  332. // !controller.apiController.isPremium &&
  333. // controller.apiController.remainTimeSeconds > 0
  334. // ? controller.apiController.remainTimeFormatted
  335. // : controller.coreController.timer,
  336. // style: TextStyle(
  337. // fontSize: 13.sp,
  338. // height: 1.5,
  339. // fontStyle: FontStyle.italic,
  340. // fontWeight: FontWeight.w500,
  341. // fontFeatures: [FontFeature.tabularFigures()],
  342. // color:
  343. // controller.apiController.userLevel == 3 ||
  344. // controller.apiController.userLevel == 9999
  345. // ? Get.reactiveTheme.textTheme.bodyLarge!.color
  346. // : Get.reactiveTheme.hintColor,
  347. // ),
  348. // ),
  349. // );
  350. return Obx(() {
  351. // 只有文案变化时才更新 UI
  352. final textColor = controller.apiController.userLevel == 3
  353. ? ReactiveTheme.isLightTheme
  354. ? LightThemeColors.homePremiumTextColor
  355. : DarkThemeColors.homePremiumTextColor
  356. : controller.apiController.userLevel == 9999
  357. ? ReactiveTheme.isLightTheme
  358. ? LightThemeColors.homeTestTextColor
  359. : DarkThemeColors.homeTestTextColor
  360. : ReactiveTheme.isLightTheme
  361. ? LightThemeColors.homeFreeTextColor
  362. : DarkThemeColors.homeFreeTextColor;
  363. return Text(
  364. controller.apiController.remainTimeFormatted,
  365. style: TextStyle(
  366. fontSize: 13.sp,
  367. height: 1.5,
  368. fontStyle: FontStyle.italic,
  369. fontWeight: FontWeight.w500,
  370. fontFeatures: [FontFeature.tabularFigures()],
  371. color: textColor,
  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.withValues(alpha: 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?.name ?? '',
  445. style: isDesktop
  446. ? TextStyle(
  447. fontSize: 14.sp,
  448. fontWeight: FontWeight.w500,
  449. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  450. )
  451. : TextStyle(
  452. fontSize: 16.sp,
  453. height: 1.5,
  454. fontWeight: FontWeight.w500,
  455. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  456. ),
  457. ),
  458. ),
  459. // 箭头图标
  460. Icon(
  461. IconFont.icon02,
  462. size: 20.w,
  463. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  464. ),
  465. ],
  466. ),
  467. ).withClickCursor(isDesktop);
  468. }),
  469. );
  470. }
  471. /// 构建最近位置卡片(支持展开/收缩)
  472. Widget _buildRecentLocationsCard() {
  473. return Obx(() {
  474. final isLight = ReactiveTheme.isLightTheme;
  475. return Container(
  476. margin: EdgeInsets.symmetric(horizontal: 10.w),
  477. padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 56.w, bottom: 0),
  478. decoration: BoxDecoration(
  479. color: Get.reactiveTheme.cardColor,
  480. borderRadius: BorderRadius.circular(12.r),
  481. border: isLight
  482. ? Border.all(color: Get.reactiveTheme.dividerColor, width: 1.w)
  483. : null,
  484. ),
  485. child: Column(
  486. children: [
  487. GestureDetector(
  488. behavior: HitTestBehavior.opaque,
  489. onTap: () {
  490. controller.isRecentLocationsExpanded =
  491. !controller.isRecentLocationsExpanded;
  492. },
  493. child: SizedBox(
  494. height: 44.w,
  495. child: Row(
  496. children: [
  497. Icon(
  498. IconFont.icon68,
  499. size: 16.w,
  500. color: Get.reactiveTheme.hintColor,
  501. ),
  502. SizedBox(width: 4.w),
  503. Text(
  504. Strings.recent.tr,
  505. style: TextStyle(
  506. fontSize: 12.sp,
  507. height: 1.2,
  508. color: Get.reactiveTheme.hintColor,
  509. ),
  510. ),
  511. const Spacer(),
  512. // 最近三个节点的国旗图标(收缩状态)或箭头(展开状态)
  513. Obx(() {
  514. return AnimatedOpacity(
  515. opacity: controller.isRecentLocationsExpanded
  516. ? 0.0
  517. : 1.0,
  518. duration: const Duration(milliseconds: 300),
  519. child: IgnorePointer(
  520. ignoring: controller.isRecentLocationsExpanded,
  521. child: Row(
  522. mainAxisSize: MainAxisSize.min,
  523. children: [
  524. ...controller.recentLocations.take(3).map((
  525. location,
  526. ) {
  527. return Container(
  528. margin: EdgeInsets.only(right: 4.w),
  529. decoration: BoxDecoration(
  530. borderRadius: BorderRadius.circular(5.r),
  531. border: Border.all(
  532. color: Get.reactiveTheme.canvasColor,
  533. width: 0.4.w,
  534. ),
  535. ),
  536. child: CountryIcon(
  537. countryCode: location.country ?? '',
  538. width: 24.w,
  539. height: 16.w,
  540. borderRadius: 4.r,
  541. ),
  542. );
  543. }),
  544. ],
  545. ),
  546. ),
  547. );
  548. }),
  549. Obx(() {
  550. return AnimatedRotation(
  551. turns: controller.isRecentLocationsExpanded
  552. ? 0.25
  553. : 0.0,
  554. duration: const Duration(milliseconds: 300),
  555. child: Icon(
  556. IconFont.icon02,
  557. size: 20.w,
  558. color: Get.reactiveTheme.hintColor,
  559. ),
  560. );
  561. }),
  562. ],
  563. ),
  564. ),
  565. ),
  566. // 最近位置列表(可折叠)
  567. Obx(() {
  568. return ClipRect(
  569. child: AnimatedAlign(
  570. duration: const Duration(milliseconds: 300),
  571. curve: Curves.easeInOut,
  572. heightFactor: controller.isRecentLocationsExpanded
  573. ? 1.0
  574. : 0.0,
  575. alignment: Alignment.topLeft,
  576. child: AnimatedOpacity(
  577. opacity: controller.isRecentLocationsExpanded ? 1.0 : 0.0,
  578. duration: const Duration(milliseconds: 300),
  579. child: Column(
  580. children: controller.recentLocations.map((location) {
  581. return ClickOpacity(
  582. onTap: () {
  583. controller.isRecentLocationsExpanded =
  584. !controller.isRecentLocationsExpanded;
  585. controller.selectLocation(location);
  586. controller.handleConnect();
  587. },
  588. child: Column(
  589. children: [
  590. Divider(
  591. height: 1,
  592. color: Get.reactiveTheme.dividerColor,
  593. ),
  594. Container(
  595. margin: EdgeInsets.symmetric(vertical: 12.h),
  596. child: Row(
  597. mainAxisAlignment: MainAxisAlignment.center,
  598. crossAxisAlignment: CrossAxisAlignment.center,
  599. children: [
  600. SizedBox(width: 2.w),
  601. // 国旗图标
  602. CountryIcon(
  603. countryCode: location.country ?? '',
  604. width: 28.w,
  605. height: 21.w,
  606. borderRadius: 4.r,
  607. ),
  608. SizedBox(width: 10.w),
  609. // 位置信息
  610. Expanded(
  611. child: Text(
  612. location.name ?? '',
  613. style: TextStyle(
  614. fontSize: 14.sp,
  615. fontWeight: FontWeight.w500,
  616. color: Get.reactiveTheme.hintColor,
  617. ),
  618. ),
  619. ),
  620. ],
  621. ),
  622. ),
  623. ],
  624. ),
  625. );
  626. }).toList(),
  627. ),
  628. ),
  629. ),
  630. );
  631. }),
  632. ],
  633. ),
  634. ).withClickCursor(isDesktop);
  635. });
  636. }
  637. }