settings_page.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import '../../services/api_service.dart';
  4. import '../../providers/app_provider.dart';
  5. class SettingsPage extends ConsumerStatefulWidget {
  6. const SettingsPage({super.key});
  7. @override
  8. ConsumerState<SettingsPage> createState() => _SettingsPageState();
  9. }
  10. class _SettingsPageState extends ConsumerState<SettingsPage> {
  11. final _urlCtrl = TextEditingController();
  12. final _tokenCtrl = TextEditingController();
  13. final _webdavUrlCtrl = TextEditingController();
  14. final _webdavUserCtrl = TextEditingController();
  15. final _webdavPassCtrl = TextEditingController();
  16. bool _isConnecting = false;
  17. bool _webdavConfigured = false;
  18. bool _showWebdavForm = false;
  19. bool _backingUp = false;
  20. String? _error;
  21. @override
  22. void initState() {
  23. super.initState();
  24. // Delay to ensure connection is fully initialized, then load
  25. Future.delayed(const Duration(milliseconds: 500), () {
  26. if (mounted) _loadWebDAVConfig();
  27. });
  28. }
  29. Future<void> _loadWebDAVConfig() async {
  30. final api = ref.read(apiServiceProvider);
  31. try {
  32. final data = await api.getWebDAVConfig();
  33. final configured = data['configured'] == true;
  34. ref.read(webdavConfiguredProvider.notifier).state = configured;
  35. ref.read(webdavUrlProvider.notifier).state = data['url'] ?? '';
  36. ref.read(webdavUserProvider.notifier).state = data['username'] ?? '';
  37. setState(() {
  38. _webdavConfigured = configured;
  39. _showWebdavForm = !configured;
  40. if (configured) {
  41. _webdavUrlCtrl.text = data['url'] ?? '';
  42. _webdavUserCtrl.text = data['username'] ?? '';
  43. }
  44. });
  45. } catch (_) {
  46. // Retry once
  47. try {
  48. final data = await api.getWebDAVConfig();
  49. final configured = data['configured'] == true;
  50. ref.read(webdavConfiguredProvider.notifier).state = configured;
  51. setState(() {
  52. _webdavConfigured = configured;
  53. _showWebdavForm = !configured;
  54. });
  55. } catch (_) {}
  56. }
  57. }
  58. @override
  59. void dispose() {
  60. _urlCtrl.dispose(); _tokenCtrl.dispose();
  61. _webdavUrlCtrl.dispose(); _webdavUserCtrl.dispose(); _webdavPassCtrl.dispose();
  62. super.dispose();
  63. }
  64. Future<void> _connect() async {
  65. final url = _urlCtrl.text.trim(), token = _tokenCtrl.text.trim();
  66. if (url.isEmpty || token.isEmpty) { setState(() => _error = '请输入服务器地址和 Token'); return; }
  67. setState(() { _isConnecting = true; _error = null; });
  68. final ok = await ref.read(connectionProvider.notifier).connect(url, token);
  69. if (!mounted) return;
  70. setState(() { _isConnecting = false; if (!ok) _error = '连接失败'; else _loadWebDAVConfig(); });
  71. }
  72. Future<void> _disconnect() async {
  73. await ref.read(connectionProvider.notifier).disconnect();
  74. _urlCtrl.clear(); _tokenCtrl.clear();
  75. _webdavConfigured = false; _showWebdavForm = false;
  76. }
  77. Future<void> _triggerFullBackup() async {
  78. final api = ref.read(apiServiceProvider);
  79. // Fire and forget — show confirmation immediately
  80. api.triggerFullBackup();
  81. if (mounted) {
  82. showDialog(
  83. context: context,
  84. builder: (ctx) => AlertDialog(
  85. title: const Text('全量备份'),
  86. content: const Text('已通知服务端开始备份。\n备份完成后将自动推送到 WebDAV。'),
  87. actions: [
  88. FilledButton(onPressed: () => Navigator.pop(ctx), child: const Text('确定')),
  89. ],
  90. ),
  91. );
  92. }
  93. }
  94. Future<void> _saveWebDAV() async {
  95. final api = ref.read(apiServiceProvider);
  96. try {
  97. await api.setWebDAVConfig(
  98. _webdavUrlCtrl.text.trim(),
  99. _webdavUserCtrl.text.trim(),
  100. _webdavPassCtrl.text.trim(),
  101. );
  102. setState(() {
  103. _webdavConfigured = true;
  104. _showWebdavForm = false;
  105. });
  106. if (mounted) {
  107. ScaffoldMessenger.of(context).showSnackBar(
  108. const SnackBar(content: Text('WebDAV 配置已保存')),
  109. );
  110. }
  111. } catch (e) {
  112. if (mounted) {
  113. ScaffoldMessenger.of(context).showSnackBar(
  114. SnackBar(content: Text('保存失败: $e')),
  115. );
  116. }
  117. }
  118. }
  119. @override
  120. Widget build(BuildContext context) {
  121. final connState = ref.watch(connectionProvider);
  122. final isConfigured = connState.status == ConnectionStatus.configured;
  123. final webdavConfigured = ref.watch(webdavConfiguredProvider);
  124. return Scaffold(
  125. appBar: AppBar(title: const Text('设置')),
  126. body: ListView(
  127. padding: const EdgeInsets.all(16),
  128. children: [
  129. // 连接状态
  130. Card(
  131. child: Padding(
  132. padding: const EdgeInsets.all(16),
  133. child: Column(
  134. crossAxisAlignment: CrossAxisAlignment.start,
  135. children: [
  136. Row(children: [
  137. Icon(isConfigured ? Icons.cloud_done : Icons.cloud_off,
  138. color: isConfigured ? Colors.green : Colors.grey),
  139. const SizedBox(width: 8),
  140. Text(isConfigured ? '已连接' : '未连接',
  141. style: Theme.of(context).textTheme.titleMedium),
  142. ]),
  143. if (isConfigured && connState.serverUrl != null)
  144. Padding(
  145. padding: const EdgeInsets.only(top: 8),
  146. child: Text('服务器: ${connState.serverUrl}',
  147. style: Theme.of(context).textTheme.bodySmall),
  148. ),
  149. ],
  150. ),
  151. ),
  152. ),
  153. const SizedBox(height: 16),
  154. // 连接表单
  155. if (!isConfigured) ...[
  156. TextField(
  157. controller: _urlCtrl,
  158. decoration: const InputDecoration(
  159. labelText: '服务器地址', hintText: 'http://数学.top:8765',
  160. prefixIcon: Icon(Icons.link), border: OutlineInputBorder(),
  161. ),
  162. keyboardType: TextInputType.url,
  163. ),
  164. const SizedBox(height: 12),
  165. TextField(
  166. controller: _tokenCtrl,
  167. decoration: const InputDecoration(
  168. labelText: 'API Token', hintText: '在服务端运行 tiku-server token 获取',
  169. prefixIcon: Icon(Icons.key), border: OutlineInputBorder(),
  170. ),
  171. obscureText: true,
  172. ),
  173. if (_error != null)
  174. Padding(padding: const EdgeInsets.only(top: 8), child: Text(_error!, style: const TextStyle(color: Colors.red))),
  175. const SizedBox(height: 16),
  176. SizedBox(
  177. width: double.infinity, height: 48,
  178. child: FilledButton(
  179. onPressed: _isConnecting ? null : _connect,
  180. child: _isConnecting
  181. ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
  182. : const Text('连接'),
  183. ),
  184. ),
  185. ] else
  186. SizedBox(width: double.infinity, height: 48,
  187. child: OutlinedButton(onPressed: _disconnect, child: const Text('断开连接'))),
  188. const SizedBox(height: 24),
  189. // WebDAV 配置
  190. if (isConfigured) ...[
  191. Card(
  192. child: webdavConfigured && !_showWebdavForm
  193. ? ListTile(
  194. leading: const Icon(Icons.cloud_done, color: Colors.green),
  195. title: const Text('WebDAV 已配置'),
  196. subtitle: Text(_webdavUrlCtrl.text),
  197. trailing: TextButton(
  198. onPressed: () => setState(() => _showWebdavForm = true),
  199. child: const Text('修改'),
  200. ),
  201. )
  202. : Padding(
  203. padding: const EdgeInsets.all(16),
  204. child: Column(
  205. crossAxisAlignment: CrossAxisAlignment.start,
  206. children: [
  207. Text('WebDAV 远程备份', style: Theme.of(context).textTheme.titleMedium),
  208. const SizedBox(height: 8),
  209. Text('备份 bank.db 到坚果云等 WebDAV 服务器',
  210. style: Theme.of(context).textTheme.bodySmall),
  211. const SizedBox(height: 12),
  212. TextField(
  213. controller: _webdavUrlCtrl,
  214. decoration: const InputDecoration(
  215. labelText: 'WebDAV 地址',
  216. hintText: 'https://dav.jianguoyun.com/dav/',
  217. border: OutlineInputBorder(), isDense: true,
  218. ),
  219. ),
  220. const SizedBox(height: 8),
  221. TextField(
  222. controller: _webdavUserCtrl,
  223. decoration: const InputDecoration(
  224. labelText: '用户名', border: OutlineInputBorder(), isDense: true,
  225. ),
  226. ),
  227. const SizedBox(height: 8),
  228. TextField(
  229. controller: _webdavPassCtrl,
  230. decoration: const InputDecoration(
  231. labelText: '密码', border: OutlineInputBorder(), isDense: true,
  232. ),
  233. obscureText: true,
  234. ),
  235. const SizedBox(height: 12),
  236. Row(
  237. children: [
  238. Expanded(
  239. child: FilledButton.tonal(
  240. onPressed: _saveWebDAV,
  241. child: const Text('保存'),
  242. ),
  243. ),
  244. if (_webdavConfigured) ...[
  245. const SizedBox(width: 8),
  246. Expanded(
  247. child: OutlinedButton(
  248. onPressed: () => setState(() => _showWebdavForm = false),
  249. child: const Text('取消'),
  250. ),
  251. ),
  252. ],
  253. ],
  254. ),
  255. ],
  256. ),
  257. ),
  258. ),
  259. ],
  260. // 全量备份
  261. if (isConfigured) ...[
  262. const SizedBox(height: 16),
  263. Card(
  264. child: Padding(
  265. padding: const EdgeInsets.all(16),
  266. child: Column(
  267. crossAxisAlignment: CrossAxisAlignment.start,
  268. children: [
  269. Text('数据备份', style: Theme.of(context).textTheme.titleMedium),
  270. const SizedBox(height: 8),
  271. Text('备份 bank.db + 全部 docx 到本地和 WebDAV',
  272. style: Theme.of(context).textTheme.bodySmall),
  273. const SizedBox(height: 12),
  274. SizedBox(
  275. width: double.infinity,
  276. child: OutlinedButton.icon(
  277. onPressed: _backingUp ? null : _triggerFullBackup,
  278. icon: _backingUp
  279. ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
  280. : const Icon(Icons.backup),
  281. label: Text(_backingUp ? '备份中...' : '全量备份'),
  282. ),
  283. ),
  284. ],
  285. ),
  286. ),
  287. ),
  288. ],
  289. const SizedBox(height: 24),
  290. // 关于
  291. Card(
  292. child: Padding(
  293. padding: const EdgeInsets.all(16),
  294. child: Column(
  295. crossAxisAlignment: CrossAxisAlignment.start,
  296. children: [
  297. Text('关于', style: Theme.of(context).textTheme.titleMedium),
  298. const SizedBox(height: 8),
  299. const Text('某人的题库 v1.0.0'),
  300. const Text('服务端: Python + FastAPI'),
  301. const Text('客户端: Flutter'),
  302. ],
  303. ),
  304. ),
  305. ),
  306. ],
  307. ),
  308. );
  309. }
  310. }