import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../i18n/messages.dart'; import '../../util/package_info.dart'; import '../../util/util.dart'; import '../../widget/check_list_view.dart'; import '../../widget/directory_selector.dart'; import '../../widget/outlined_button_loading.dart'; import '../app/app_controller.dart'; import 'setting_controller.dart'; const _padding = SizedBox(height: 10); final _divider = const Divider().paddingOnly(left: 10, right: 10); class SettingView extends GetView { const SettingView({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final appController = Get.find(); final downloaderCfg = appController.downloaderConfig; final startCfg = appController.startConfig; Timer? timer; debounceSave({bool needRestart = false}) { var completer = Completer(); timer?.cancel(); timer = Timer(const Duration(milliseconds: 500), () { appController .saveConfig() .then(completer.complete) .onError(completer.completeError); if (needRestart) { Get.snackbar('tip'.tr, 'setting.effectAfterRestart'.tr); } }); return completer.future; } // download basic config items start final buildDownloadDir = _buildConfigItem( 'setting.downloadDir', () => downloaderCfg.value.downloadDir, (Key key) { final downloadDirController = TextEditingController(text: downloaderCfg.value.downloadDir); downloadDirController.addListener(() async { if (downloadDirController.text != downloaderCfg.value.downloadDir) { downloaderCfg.value.downloadDir = downloadDirController.text; if (Util.isDesktop()) { controller.clearTap(); } await debounceSave(); } }); return DirectorySelector( controller: downloadDirController, showLabel: false, ); }); // http config items start final httpConfig = downloaderCfg.value.protocolConfig.http; final buildHttpConnections = _buildConfigItem( 'setting.connections'.tr, () => httpConfig.connections.toString(), (Key key) { final connectionsController = TextEditingController(text: httpConfig.connections.toString()); connectionsController.addListener(() async { if (connectionsController.text.isNotEmpty && connectionsController.text != httpConfig.connections.toString()) { httpConfig.connections = int.parse(connectionsController.text); await debounceSave(); } }); return TextField( key: key, focusNode: FocusNode(), controller: connectionsController, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, _NumericalRangeFormatter(min: 1, max: 256), ], ); }); // bt config items start final btExtConfig = downloaderCfg.value.extra.bt; final buildBtTrackerSubscribeUrls = _buildConfigItem( 'setting.subscribeTracker'.tr, () => 'setting.items'.trParams( {'count': btExtConfig.trackerSubscribeUrls.length.toString()}), (Key key) { final trackerUpdateController = OutlinedButtonLoadingController(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 200, child: CheckListView( items: allTrackerSubscribeUrls, checked: btExtConfig.trackerSubscribeUrls, onChanged: (value) { btExtConfig.trackerSubscribeUrls = value; debounceSave(); }, ), ), _padding, Row( children: [ OutlinedButtonLoading( onPressed: () async { trackerUpdateController.start(); try { await appController.trackerUpdate(); } catch (e) { Get.snackbar('error'.tr, 'setting.subscribeFail'.tr); } finally { trackerUpdateController.stop(); } }, controller: trackerUpdateController, child: Text('setting.update'.tr), ), Flexible( child: SizedBox( width: 200, child: SwitchListTile( controlAffinity: ListTileControlAffinity.leading, value: true, onChanged: (bool value) {}, title: Text('setting.updateDaily'.tr)), ), ), ], ), Text('setting.lastUpdate'.trParams({ 'time': btExtConfig.lastTrackerUpdateTime != null ? DateFormat('yyyy-MM-dd HH:mm:ss') .format(btExtConfig.lastTrackerUpdateTime!) : '' })), ], ); }); final buildBtTrackers = _buildConfigItem( 'setting.addTracker'.tr, () => 'setting.items' .trParams({'count': btExtConfig.customTrackers.length.toString()}), (Key key) { final trackersController = TextEditingController( text: btExtConfig.customTrackers.join('\r\n').toString()); const ls = LineSplitter(); return TextField( key: key, focusNode: FocusNode(), controller: trackersController, keyboardType: TextInputType.multiline, maxLines: 5, decoration: InputDecoration( hintText: 'setting.addTrackerHit'.tr, ), onChanged: (value) async { btExtConfig.customTrackers = ls.convert(value); appController.refreshTrackers(); await debounceSave(); }, ); }); // ui config items start final buildTheme = _buildConfigItem( 'setting.theme', () => _getThemeName(downloaderCfg.value.extra.themeMode), (Key key) => DropdownButton( key: key, value: downloaderCfg.value.extra.themeMode, onChanged: (value) async { downloaderCfg.update((val) { val?.extra.themeMode = value!; }); Get.changeThemeMode(ThemeMode.values.byName(value!)); controller.clearTap(); await debounceSave(); }, items: ThemeMode.values .map((e) => DropdownMenuItem( value: e.name, child: Text(_getThemeName(e.name)), )) .toList(), )); final buildLocale = _buildConfigItem( 'setting.locale', () => messages.keys[downloaderCfg.value.extra.locale]!['label']!, (Key key) => DropdownButton( key: key, value: downloaderCfg.value.extra.locale, isDense: true, onChanged: (value) async { downloaderCfg.update((val) { val!.extra.locale = value!; }); Get.updateLocale(toLocale(value!)); controller.clearTap(); await debounceSave(); }, items: messages.keys.keys .map((e) => DropdownMenuItem( value: e, child: Text(messages.keys[e]!['label']!), )) .toList(), )); // about config items start buildHomepage() => ListTile( title: Text('setting.homepage'.tr), subtitle: const Text('https://github.com/monkeyWie/gopeed'), onTap: () { launchUrl(Uri.parse('https://github.com/monkeyWie/gopeed')); }, ); buildVersion() => ListTile( title: Text('setting.version'.tr), subtitle: Text(packageInfo.version), ); // advanced config items start final buildApiProtocol = _buildConfigItem( 'setting.protocol'.tr, () => startCfg.value.network == 'tcp' ? 'TCP ${startCfg.value.address}' : 'Unix', (Key key) { final items = [ SizedBox( width: 150, child: DropdownButtonFormField( value: startCfg.value.network, onChanged: (value) async { startCfg.update((val) { val!.network = value!; }); await debounceSave(needRestart: true); }, items: [ !Util.isMobile() ? const DropdownMenuItem( value: 'tcp', child: Text('TCP'), ) : null, Util.isUnix() ? const DropdownMenuItem( value: 'unix', child: Text('Unix'), ) : null, ].where((e) => e != null).map((e) => e!).toList(), ), ) ]; if (Util.isDesktop() && startCfg.value.network == 'tcp') { final arr = startCfg.value.address.split(':'); var ip = '127.0.0.1'; var port = '0'; if (arr.length > 1) { ip = arr[0]; port = arr[1]; } final ipController = TextEditingController(text: ip); final portController = TextEditingController(text: port); updateAddress() async { final newAddress = '${ipController.text}:${portController.text}'; if (newAddress != startCfg.value.address) { startCfg.value.address = newAddress; await debounceSave(needRestart: true); } } ipController.addListener(updateAddress); portController.addListener(updateAddress); items.addAll([ const Padding(padding: EdgeInsets.only(left: 20)), SizedBox( width: 200, child: TextFormField( controller: ipController, decoration: const InputDecoration( labelText: 'IP', contentPadding: EdgeInsets.all(0.0), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.allow(RegExp('[0-9.]')), ], ), ), const Padding(padding: EdgeInsets.only(left: 10)), SizedBox( width: 200, child: TextFormField( controller: portController, decoration: InputDecoration( labelText: 'setting.port'.tr, contentPadding: const EdgeInsets.all(0.0), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, _NumericalRangeFormatter(min: 0, max: 65535), ], ), ), ]); } return Form( child: Row( children: items, ), ); }, ); final buildApiToken = _buildConfigItem( 'setting.apiToken'.tr, () => startCfg.value.apiToken.isEmpty ? 'setting.notSet'.tr : 'setting.seted'.tr, (Key key) { final apiTokenController = TextEditingController(text: startCfg.value.apiToken); apiTokenController.addListener(() async { if (apiTokenController.text != startCfg.value.apiToken) { startCfg.value.apiToken = apiTokenController.text; await debounceSave(needRestart: true); } }); apiTokenController.addListener(() async {}); return TextField( key: key, obscureText: true, controller: apiTokenController, focusNode: FocusNode(), ); }); return Obx(() { return GestureDetector( onTap: () { controller.clearTap(); }, child: DefaultTabController( length: 2, child: Scaffold( appBar: PreferredSize( preferredSize: Size.fromHeight(56), child: AppBar( bottom: TabBar( tabs: [ Tab( text: 'setting.basic'.tr, ), Tab( text: 'setting.advanced'.tr, ), ], ), )), body: TabBarView( children: [ SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: _addPadding([ Text('setting.general'.tr), Card( child: Column( children: _addDivider([ buildDownloadDir(), ]), )), const Text('HTTP'), Card( child: Column( children: _addDivider([ buildHttpConnections(), ]), )), const Text('BitTorrent'), Card( child: Column( children: _addDivider([ buildBtTrackerSubscribeUrls(), buildBtTrackers(), ]), )), Text('setting.ui'.tr), Card( child: Column( children: _addDivider([ buildTheme(), buildLocale(), ]), )), Text('setting.about'.tr), Card( child: Column( children: _addDivider([ buildHomepage(), buildVersion(), ]), )), ]), ), ), Column( children: [ Card( child: Column( children: [ ..._addDivider([ buildApiProtocol(), Util.isDesktop() && startCfg.value.network == 'tcp' ? buildApiToken() : null, ]), ], )), ], ), ], ).paddingOnly(left: 16, right: 16, top: 16, bottom: 64)), ), ); }); } void _tapInputWidget(GlobalKey key) { if (key.currentContext == null) { return; } if (key.currentContext?.widget is TextField) { final textField = key.currentContext?.widget as TextField; textField.focusNode?.requestFocus(); return; } /* GestureDetector? detector; void searchForGestureDetector(BuildContext? element) { element?.visitChildElements((element) { if (element.widget is GestureDetector) { detector = element.widget as GestureDetector?; } else { searchForGestureDetector(element); } }); } searchForGestureDetector(key.currentContext); detector?.onTap?.call(); */ } Widget Function() _buildConfigItem( String label, String Function() text, Widget Function(Key key) input) { final tapStatues = controller.tapStatues; final inputKey = GlobalKey(); return () => ListTile( title: Text(label.tr), subtitle: tapStatues[label] ?? false ? input(inputKey) : Text(text()), onTap: () { controller.onTap(label); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _tapInputWidget(inputKey); }); }); } List _addPadding(List widgets) { final result = []; for (var i = 0; i < widgets.length; i++) { result.add(widgets[i]); result.add(_padding); } return result; } List _addDivider(List widgets) { final result = []; final newArr = widgets.where((e) => e != null).map((e) => e!).toList(); for (var i = 0; i < newArr.length; i++) { result.add(newArr[i]); if (i != newArr.length - 1) { result.add(_divider); } } return result; } String _getThemeName(String? themeMode) { switch (ThemeMode.values.byName(themeMode ?? ThemeMode.system.name)) { case ThemeMode.light: return 'setting.themeLight'.tr; case ThemeMode.dark: return 'setting.themeDark'.tr; default: return 'setting.themeSystem'.tr; } } } class _ConfigItem { late String label; late String Function() text; late Widget Function(Key key) inputItem; _ConfigItem(this.label, this.text, this.inputItem); } class _NumericalRangeFormatter extends TextInputFormatter { final int min; final int max; _NumericalRangeFormatter({required this.min, required this.max}); @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { if (newValue.text.isEmpty) { return newValue; } var intVal = int.tryParse(newValue.text); if (intVal == null) { return oldValue; } if (intVal < min) { return newValue.copyWith(text: min.toString()); } else if (intVal > max) { return oldValue.copyWith(text: max.toString()); } else { return newValue; } } }