splittunneling_selectapp_view.dart 9.8 KB

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