splittunneling_selectapp_view.dart 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_screenutil/flutter_screenutil.dart';
  3. import 'package:get/get.dart';
  4. import 'package:nomo/app/base/base_view.dart';
  5. import 'package:nomo/app/widgets/click_opacity.dart';
  6. import 'package:nomo/app/widgets/ix_app_bar.dart';
  7. import 'package:nomo/app/widgets/ix_image.dart';
  8. import 'package:nomo/config/theme/theme_extensions/theme_extension.dart';
  9. import '../../../../../utils/event_bus.dart';
  10. import '../../../../constants/iconfont/iconfont.dart';
  11. import '../../../../widgets/state/state_wrapper.dart';
  12. import '../controllers/splittunneling_selectapp_controller.dart';
  13. class SplittunnelingSelectappView
  14. extends BaseView<SplittunnelingSelectappController> {
  15. SplittunnelingSelectappView({super.key});
  16. // 用于存储应用项的位置信息
  17. final Map<String, GlobalKey> _selectedAppsKeys = {};
  18. final Map<String, GlobalKey> _unselectedAppsKeys = {};
  19. @override
  20. PreferredSizeWidget? get appBar => IXAppBar(
  21. title: 'Split Tunneling',
  22. onBackPressed: () {
  23. eventBus.fire(
  24. SplitTunnelingPageEvent(mode: controller.currentMode.value),
  25. );
  26. Get.back();
  27. },
  28. );
  29. @override
  30. Widget buildContent(BuildContext context) {
  31. return StateWrapper(
  32. controller: controller,
  33. onRefresh: () => controller.getApps(),
  34. child: SingleChildScrollView(
  35. padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.w),
  36. child: Column(
  37. crossAxisAlignment: CrossAxisAlignment.start,
  38. children: [
  39. // 信息横幅
  40. _buildInfoBanner(),
  41. // 已选择应用区域
  42. _buildSelectedAppsSection(context),
  43. 10.verticalSpaceFromWidth,
  44. // 所有应用区域
  45. _buildAllAppsSection(context),
  46. ],
  47. ),
  48. ),
  49. );
  50. }
  51. /// 构建信息横幅
  52. Widget _buildInfoBanner() {
  53. return Obx(() {
  54. final isExcludeMode =
  55. controller.currentMode.value == SplitTunnelingMode.exclude;
  56. final message = isExcludeMode
  57. ? 'Select apps that will not use the VPN'
  58. : 'Select apps that will use the VPN';
  59. return Container(
  60. padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.w),
  61. decoration: BoxDecoration(
  62. color: Get.reactiveTheme.cardColor,
  63. borderRadius: BorderRadius.circular(12.r),
  64. ),
  65. child: Row(
  66. children: [
  67. Icon(
  68. IconFont.icon22,
  69. size: 20.w,
  70. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  71. ),
  72. SizedBox(width: 12.w),
  73. Expanded(
  74. child: Text(
  75. message,
  76. style: TextStyle(
  77. fontSize: 12.sp,
  78. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  79. fontWeight: FontWeight.w500,
  80. ),
  81. ),
  82. ),
  83. ],
  84. ),
  85. );
  86. });
  87. }
  88. /// 构建已选择应用区域
  89. Widget _buildSelectedAppsSection(BuildContext context) {
  90. return Obx(() {
  91. if (controller.selectedApps.isEmpty) {
  92. return const SizedBox.shrink();
  93. }
  94. return AnimatedContainer(
  95. duration: const Duration(milliseconds: 300),
  96. curve: Curves.easeInOut,
  97. child: Column(
  98. crossAxisAlignment: CrossAxisAlignment.start,
  99. children: [
  100. 10.verticalSpaceFromWidth,
  101. Row(
  102. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  103. children: [
  104. Text(
  105. 'Select apps',
  106. style: TextStyle(
  107. fontSize: 16.sp,
  108. fontWeight: FontWeight.w500,
  109. color: Get.reactiveTheme.hintColor,
  110. ),
  111. ),
  112. ClickOpacity(
  113. onTap: controller.deselectAllApps,
  114. child: Text(
  115. 'Deselect all',
  116. style: TextStyle(
  117. fontSize: 14.sp,
  118. color: Get.reactiveTheme.shadowColor,
  119. fontWeight: FontWeight.w500,
  120. ),
  121. ),
  122. ),
  123. ],
  124. ),
  125. 10.verticalSpaceFromWidth,
  126. Container(
  127. decoration: BoxDecoration(
  128. color: Get.reactiveTheme.highlightColor,
  129. borderRadius: BorderRadius.circular(12.r),
  130. ),
  131. child: Column(
  132. children: controller.selectedApps.asMap().entries.map((entry) {
  133. final key = GlobalKey();
  134. _selectedAppsKeys[entry.value.packageName] = key;
  135. return Container(
  136. key: key,
  137. child: _buildAppItem(
  138. context,
  139. entry.value,
  140. isLast: entry.key == controller.selectedApps.length - 1,
  141. ),
  142. );
  143. }).toList(),
  144. ),
  145. ),
  146. ],
  147. ),
  148. );
  149. });
  150. }
  151. /// 构建所有应用区域
  152. Widget _buildAllAppsSection(BuildContext context) {
  153. return Column(
  154. crossAxisAlignment: CrossAxisAlignment.start,
  155. children: [
  156. Text(
  157. 'All apps',
  158. style: TextStyle(
  159. fontSize: 16.sp,
  160. fontWeight: FontWeight.w500,
  161. color: Get.reactiveTheme.hintColor,
  162. ),
  163. ),
  164. 10.verticalSpaceFromWidth,
  165. Obx(() {
  166. final unselectedApps = controller.unselectedApps;
  167. return Container(
  168. decoration: BoxDecoration(
  169. color: Get.reactiveTheme.highlightColor,
  170. borderRadius: BorderRadius.circular(12.r),
  171. ),
  172. child: Column(
  173. children: unselectedApps.asMap().entries.map((entry) {
  174. final key = GlobalKey();
  175. _unselectedAppsKeys[entry.value.packageName] = key;
  176. return Container(
  177. key: key,
  178. child: _buildAppItem(
  179. context,
  180. entry.value,
  181. isLast: entry.key == unselectedApps.length - 1,
  182. ),
  183. );
  184. }).toList(),
  185. ),
  186. );
  187. }),
  188. ],
  189. );
  190. }
  191. /// 构建应用项
  192. Widget _buildAppItem(
  193. BuildContext context,
  194. AppInfo app, {
  195. bool isLast = false,
  196. }) {
  197. return AnimatedContainer(
  198. duration: const Duration(milliseconds: 200),
  199. curve: Curves.easeInOut,
  200. child: GestureDetector(
  201. onTap: () {
  202. // 直接切换选择状态
  203. controller.toggleAppSelection(app);
  204. },
  205. child: AnimatedContainer(
  206. duration: const Duration(milliseconds: 150),
  207. curve: Curves.easeInOut,
  208. child: Container(
  209. height: 68.w,
  210. padding: EdgeInsets.symmetric(horizontal: 14.w),
  211. decoration: BoxDecoration(
  212. border: isLast
  213. ? null
  214. : Border(
  215. bottom: BorderSide(
  216. color: Get.reactiveTheme.dividerColor,
  217. width: 1.w,
  218. ),
  219. ),
  220. ),
  221. child: Row(
  222. children: [
  223. // 应用图标
  224. AnimatedContainer(
  225. duration: const Duration(milliseconds: 200),
  226. curve: Curves.easeInOut,
  227. child: Transform.scale(
  228. scale: 1.0,
  229. child: IXImage(
  230. key: ValueKey('app_icon_${app.packageName}'),
  231. source: app.icon,
  232. width: 40.w,
  233. height: 40.w,
  234. sourceType: ImageSourceType.memory,
  235. ),
  236. ),
  237. ),
  238. 12.horizontalSpace,
  239. // 应用名称
  240. Expanded(
  241. child: Text(
  242. app.name,
  243. style: TextStyle(
  244. fontSize: 14.sp,
  245. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  246. fontWeight: FontWeight.w500,
  247. ),
  248. ),
  249. ),
  250. // 选择状态指示器
  251. Obx(() {
  252. final isSelected = controller.isAppSelected(app);
  253. return AnimatedContainer(
  254. duration: const Duration(milliseconds: 300),
  255. curve: Curves.elasticOut,
  256. width: 20.w,
  257. height: 20.w,
  258. decoration: BoxDecoration(
  259. shape: BoxShape.circle,
  260. color: isSelected
  261. ? Get.reactiveTheme.shadowColor
  262. : Colors.transparent,
  263. border: Border.all(
  264. color: isSelected
  265. ? Get.reactiveTheme.shadowColor
  266. : Colors.grey[400]!,
  267. width: 1.5.w,
  268. ),
  269. ),
  270. child: AnimatedScale(
  271. scale: isSelected ? 1.0 : 0.0,
  272. duration: const Duration(milliseconds: 300),
  273. curve: Curves.elasticOut,
  274. child: Icon(Icons.check, color: Colors.white, size: 14.w),
  275. ),
  276. );
  277. }),
  278. ],
  279. ),
  280. ),
  281. ),
  282. ),
  283. );
  284. }
  285. }