home_view.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import 'dart:io';
  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/theme_extensions/theme_extension.dart';
  9. import '../../../../config/translations/strings_enum.dart';
  10. import '../../../base/base_view.dart';
  11. import '../../../constants/assets.dart';
  12. import '../../../routes/app_pages.dart';
  13. import '../../../widgets/click_opacity.dart';
  14. import '../../../widgets/country_icon.dart';
  15. import '../../../widgets/ix_image.dart';
  16. import '../widgets/connection_button.dart';
  17. import '../controllers/home_controller.dart';
  18. import '../widgets/menu_list.dart';
  19. class HomeView extends BaseView<HomeController> {
  20. const HomeView({super.key});
  21. @override
  22. bool get isPopScope => true;
  23. @override
  24. Widget buildContent(BuildContext context) {
  25. return SafeArea(
  26. child: Container(
  27. margin: EdgeInsets.symmetric(horizontal: 14.w),
  28. child: Stack(
  29. children: [
  30. Column(
  31. children: [
  32. Row(
  33. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  34. children: [
  35. Obx(
  36. () => ClickOpacity(
  37. onTap: () => Get.toNamed(Routes.SUBSCRIPTION),
  38. child: IXImage(
  39. source: controller.apiController.userLevel == 3
  40. ? Assets.premium
  41. : controller.apiController.userLevel == 9999
  42. ? Assets.test
  43. : Assets.free,
  44. width: controller.apiController.userLevel == 3
  45. ? 92.w
  46. : 64.w,
  47. height: 28.w,
  48. sourceType: ImageSourceType.asset,
  49. ),
  50. ),
  51. ),
  52. ClickOpacity(
  53. child: Padding(
  54. padding: EdgeInsets.only(
  55. left: 10.w,
  56. right: 0.w,
  57. top: 10.w,
  58. bottom: 10.w,
  59. ),
  60. child: Icon(
  61. IconFont.icon09,
  62. size: 26.w,
  63. color: Get.reactiveTheme.hintColor,
  64. ),
  65. ),
  66. onTap: () {
  67. Get.toNamed(Routes.SETTING);
  68. // ErrorDialog.show(
  69. // message:
  70. // "The VPN was disconnected unexpectedly. Would you like to reconnect now to stay protected?",
  71. // );
  72. },
  73. ),
  74. ],
  75. ),
  76. Expanded(
  77. child: SmartRefresher(
  78. enablePullDown: true,
  79. enablePullUp: false,
  80. controller: controller.refreshController,
  81. onRefresh: controller.onRefresh,
  82. child: SingleChildScrollView(
  83. physics: const ClampingScrollPhysics(),
  84. child: Column(
  85. crossAxisAlignment: CrossAxisAlignment.start,
  86. children: [
  87. // 80.verticalSpaceFromWidth,
  88. Padding(
  89. padding: EdgeInsets.symmetric(vertical: 20.w),
  90. child: CarouselSlider(
  91. options: CarouselOptions(
  92. height: 80.w,
  93. viewportFraction: 1.0,
  94. ),
  95. items: [1, 2, 3, 4, 5].map((i) {
  96. return Builder(
  97. builder: (BuildContext context) {
  98. return IXImage(
  99. source: Assets.bannerTest,
  100. width: double.infinity,
  101. height: 80.w,
  102. sourceType: ImageSourceType.asset,
  103. borderRadius: 14.r,
  104. );
  105. },
  106. );
  107. }).toList(),
  108. ),
  109. ),
  110. Text(
  111. controller.apiController.isGuest &&
  112. !controller.apiController.isPremium &&
  113. controller.apiController.remainTimeSeconds >
  114. 0
  115. ? Strings.remainTime.tr
  116. : Strings.activeTime.tr,
  117. style: TextStyle(
  118. fontSize: 18.sp,
  119. height: 1.3,
  120. color: Get.reactiveTheme.hintColor,
  121. ),
  122. ),
  123. 2.verticalSpaceFromWidth,
  124. Obx(
  125. () => Text(
  126. controller.apiController.isGuest &&
  127. !controller.apiController.isPremium &&
  128. controller
  129. .apiController
  130. .remainTimeSeconds >
  131. 0
  132. ? controller.apiController.remainTimeFormatted
  133. : controller.coreController.timer,
  134. style: TextStyle(
  135. fontSize: 28.sp,
  136. height: 1.2,
  137. color: Get.reactiveTheme.primaryColor,
  138. ),
  139. ),
  140. ),
  141. 20.verticalSpaceFromWidth,
  142. // 位置选择按钮和最近位置(叠在一起的效果)
  143. Stack(
  144. children: [
  145. Container(
  146. alignment: Alignment.center,
  147. margin: EdgeInsets.only(top: 138.w),
  148. child: _buildConnectionButton(),
  149. ),
  150. _buildLocationStack(),
  151. ],
  152. ),
  153. ],
  154. ),
  155. ),
  156. ),
  157. ),
  158. ],
  159. ),
  160. Positioned(
  161. bottom: Platform.isAndroid ? 10.w : 0,
  162. left: 0,
  163. right: 0,
  164. child: MenuList(),
  165. ),
  166. ],
  167. ),
  168. ),
  169. );
  170. }
  171. Widget _buildConnectionButton() {
  172. return Obx(
  173. () => ConnectionButton(
  174. state: controller.coreController.state,
  175. onTap: () {
  176. controller.setDefaultAutoConnect();
  177. },
  178. ),
  179. );
  180. }
  181. /// 构建位置堆叠效果(选中位置 + 最近位置)
  182. Widget _buildLocationStack() {
  183. return Obx(() {
  184. if (controller.selectedLocation == null) {
  185. return const SizedBox.shrink();
  186. }
  187. return Stack(
  188. children: [
  189. // 最近位置列表(背景层)
  190. if (controller.recentLocations.isNotEmpty)
  191. _buildRecentLocationsCard(),
  192. // 选中位置(前景层)
  193. _buildSelectedLocationCard(),
  194. ],
  195. );
  196. });
  197. }
  198. /// 构建选中位置卡片
  199. Widget _buildSelectedLocationCard() {
  200. return GestureDetector(
  201. onTap: () {
  202. Get.toNamed(Routes.NODE);
  203. },
  204. child: Obx(() {
  205. return Container(
  206. height: 56.w,
  207. width: double.maxFinite,
  208. padding: EdgeInsets.only(left: 16.w, right: 10.w),
  209. decoration: BoxDecoration(
  210. color: Get.reactiveTheme.highlightColor,
  211. borderRadius: BorderRadius.circular(12.r),
  212. ),
  213. child: Row(
  214. children: [
  215. // 国旗图标
  216. CountryIcon(
  217. countryCode: controller.selectedLocation?.country ?? '',
  218. width: 32.w,
  219. height: 24.w,
  220. borderRadius: 4.r,
  221. ),
  222. 10.horizontalSpace,
  223. // 位置名称
  224. Expanded(
  225. child: Text(
  226. '${controller.selectedLocation?.code ?? ''} - ${controller.selectedLocation?.name ?? ''}',
  227. style: TextStyle(
  228. fontSize: 16.sp,
  229. height: 1.5,
  230. fontWeight: FontWeight.w500,
  231. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  232. ),
  233. ),
  234. ),
  235. // 箭头图标
  236. Icon(
  237. IconFont.icon02,
  238. size: 20.w,
  239. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  240. ),
  241. ],
  242. ),
  243. );
  244. }),
  245. );
  246. }
  247. /// 构建最近位置卡片(支持展开/收缩)
  248. Widget _buildRecentLocationsCard() {
  249. return Obx(() {
  250. return Container(
  251. margin: EdgeInsets.symmetric(horizontal: 10.w),
  252. padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 56.w, bottom: 0),
  253. decoration: BoxDecoration(
  254. color: Get.reactiveTheme.cardColor,
  255. borderRadius: BorderRadius.circular(12.r),
  256. ),
  257. child: Column(
  258. children: [
  259. GestureDetector(
  260. behavior: HitTestBehavior.opaque,
  261. onTap: () {
  262. controller.isRecentLocationsExpanded =
  263. !controller.isRecentLocationsExpanded;
  264. },
  265. child: SizedBox(
  266. height: 44.w,
  267. child: Row(
  268. children: [
  269. Icon(
  270. IconFont.icon68,
  271. size: 16.w,
  272. color: Get.reactiveTheme.hintColor,
  273. ),
  274. SizedBox(width: 4.w),
  275. Text(
  276. Strings.recent.tr,
  277. style: TextStyle(
  278. fontSize: 12.sp,
  279. height: 1.2,
  280. color: Get.reactiveTheme.hintColor,
  281. ),
  282. ),
  283. const Spacer(),
  284. // 最近三个节点的国旗图标(收缩状态)或箭头(展开状态)
  285. Obx(() {
  286. return AnimatedOpacity(
  287. opacity: controller.isRecentLocationsExpanded
  288. ? 0.0
  289. : 1.0,
  290. duration: const Duration(milliseconds: 300),
  291. child: IgnorePointer(
  292. ignoring: controller.isRecentLocationsExpanded,
  293. child: Row(
  294. mainAxisSize: MainAxisSize.min,
  295. children: [
  296. ...controller.recentLocations.take(3).map((
  297. location,
  298. ) {
  299. return Container(
  300. margin: EdgeInsets.only(right: 4.w),
  301. decoration: BoxDecoration(
  302. borderRadius: BorderRadius.circular(5.r),
  303. border: Border.all(
  304. color: Get.reactiveTheme.canvasColor,
  305. width: 0.4.w,
  306. ),
  307. ),
  308. child: CountryIcon(
  309. countryCode: location.country ?? '',
  310. width: 24.w,
  311. height: 16.w,
  312. borderRadius: 4.r,
  313. ),
  314. );
  315. }),
  316. ],
  317. ),
  318. ),
  319. );
  320. }),
  321. Obx(() {
  322. return AnimatedRotation(
  323. turns: controller.isRecentLocationsExpanded
  324. ? 0.25
  325. : 0.0,
  326. duration: const Duration(milliseconds: 300),
  327. child: Icon(
  328. IconFont.icon02,
  329. size: 20.w,
  330. color: Get.reactiveTheme.hintColor,
  331. ),
  332. );
  333. }),
  334. ],
  335. ),
  336. ),
  337. ),
  338. // 最近位置列表(可折叠)
  339. Obx(() {
  340. return ClipRect(
  341. child: AnimatedAlign(
  342. duration: const Duration(milliseconds: 300),
  343. curve: Curves.easeInOut,
  344. heightFactor: controller.isRecentLocationsExpanded
  345. ? 1.0
  346. : 0.0,
  347. alignment: Alignment.topLeft,
  348. child: AnimatedOpacity(
  349. opacity: controller.isRecentLocationsExpanded ? 1.0 : 0.0,
  350. duration: const Duration(milliseconds: 300),
  351. child: Column(
  352. children: controller.recentLocations.map((location) {
  353. return ClickOpacity(
  354. onTap: () {
  355. controller.isRecentLocationsExpanded =
  356. !controller.isRecentLocationsExpanded;
  357. controller.selectLocation(location);
  358. controller.handleConnect();
  359. },
  360. child: Column(
  361. children: [
  362. Divider(
  363. height: 1,
  364. color: Get.reactiveTheme.dividerColor,
  365. ),
  366. Container(
  367. margin: EdgeInsets.symmetric(vertical: 12.h),
  368. child: Row(
  369. mainAxisAlignment: MainAxisAlignment.center,
  370. crossAxisAlignment: CrossAxisAlignment.center,
  371. children: [
  372. // 国旗图标
  373. CountryIcon(
  374. countryCode: location.country ?? '',
  375. width: 28.w,
  376. height: 21.w,
  377. borderRadius: 4.r,
  378. ),
  379. SizedBox(width: 10.w),
  380. // 位置信息
  381. Expanded(
  382. child: Text(
  383. '${location.code} - ${location.name}',
  384. style: TextStyle(
  385. fontSize: 14.sp,
  386. fontWeight: FontWeight.w500,
  387. color: Get.reactiveTheme.hintColor,
  388. ),
  389. ),
  390. ),
  391. ],
  392. ),
  393. ),
  394. ],
  395. ),
  396. );
  397. }).toList(),
  398. ),
  399. ),
  400. ),
  401. );
  402. }),
  403. ],
  404. ),
  405. );
  406. });
  407. }
  408. }