import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/providers.dart'; import '../services/ntfy_settings.dart'; import '../services/reader_direction.dart'; import '../services/webdav_settings.dart'; class SettingsPage extends ConsumerStatefulWidget { const SettingsPage({super.key}); @override ConsumerState createState() => _SettingsPageState(); } class _SettingsPageState extends ConsumerState { final _urlCtrl = TextEditingController(); final _userCtrl = TextEditingController(); final _passCtrl = TextEditingController(); final _pathCtrl = TextEditingController(); final _ntfyCompletedCtrl = TextEditingController(); final _ntfyFailedCtrl = TextEditingController(); final _ntfyDrainedCtrl = TextEditingController(); final _ntfyTokenCtrl = TextEditingController(); bool _enabled = false; bool _ntfyEnabled = false; NtfyAttachmentMode _ntfyAttachment = NtfyAttachmentMode.none; bool _loading = true; bool _busy = false; String? _status; String? _error; DateTime? _lastSync; @override void initState() { super.initState(); _load(); } @override void dispose() { _urlCtrl.dispose(); _userCtrl.dispose(); _passCtrl.dispose(); _pathCtrl.dispose(); _ntfyCompletedCtrl.dispose(); _ntfyFailedCtrl.dispose(); _ntfyDrainedCtrl.dispose(); _ntfyTokenCtrl.dispose(); super.dispose(); } Future _load() async { final s = await WebDavSettings.load(); final n = await NtfySettings.load(); if (!mounted) return; setState(() { _enabled = s.enabled; _urlCtrl.text = s.url; _userCtrl.text = s.username; _passCtrl.text = s.password; _pathCtrl.text = s.path; _status = s.lastStatus; _error = s.lastError; _lastSync = s.lastSyncAt; _ntfyEnabled = n.enabled; _ntfyCompletedCtrl.text = n.completedUrl; _ntfyFailedCtrl.text = n.failedUrl; _ntfyDrainedCtrl.text = n.drainedUrl; _ntfyTokenCtrl.text = n.authToken; _ntfyAttachment = n.attachmentMode; _loading = false; }); } Future _save() async { await WebDavSettings.save( enabled: _enabled, url: _urlCtrl.text, username: _userCtrl.text, password: _passCtrl.text, path: _pathCtrl.text, ); await NtfySettings.save( enabled: _ntfyEnabled, completedUrl: _ntfyCompletedCtrl.text, failedUrl: _ntfyFailedCtrl.text, drainedUrl: _ntfyDrainedCtrl.text, attachmentMode: _ntfyAttachment, authToken: _ntfyTokenCtrl.text, ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Settings saved')), ); } Future _test() async { setState(() => _busy = true); try { await _save(); final msg = await ref.read(webDavSyncProvider).testConnection(); if (!mounted) return; setState(() { _status = msg; _error = null; }); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } catch (e) { if (!mounted) return; setState(() { _status = 'Test failed'; _error = e.toString(); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Test failed: $e')), ); } finally { if (mounted) setState(() => _busy = false); } } Future _syncNow() async { setState(() => _busy = true); try { await _save(); await ref.read(webDavSyncProvider).sync(); await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Sync finished')), ); } catch (e) { await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Sync failed: $e')), ); } finally { if (mounted) setState(() => _busy = false); } } Future _testNtfy(String kind, TextEditingController ctrl) async { setState(() => _busy = true); try { await _save(); await ref.read(ntfyNotifierProvider).testTopic(ctrl.text, kind: kind); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Test notification sent')), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('ntfy test failed: $e')), ); } finally { if (mounted) setState(() => _busy = false); } } Widget _ntfyUrlRow({ required String label, required TextEditingController controller, required String kind, }) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: controller, decoration: InputDecoration( labelText: label, hintText: 'https://ntfy.sh/mytopic', ), keyboardType: TextInputType.url, autocorrect: false, enabled: _ntfyEnabled, ), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: !_ntfyEnabled || _busy ? null : () => _testNtfy(kind, controller), child: const Text('Test'), ), ), ], ); } @override Widget build(BuildContext context) { final direction = ref.watch(readingDirectionProvider); if (_loading) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } return Scaffold( appBar: AppBar( title: const Text('Settings'), actions: [ TextButton( onPressed: _busy ? null : _save, child: const Text('Save'), ), ], ), body: SafeArea( top: false, child: ListView( padding: const EdgeInsets.all(16), children: [ Text('Reader', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), Text( 'Fallback page direction when a title has no saved preference ' '(ComicLand). MangaRead / MangaTown default to Right → Left; ' 'overrides are saved per title and sync via WebDAV.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), SegmentedButton( segments: const [ ButtonSegment( value: ReadingDirection.ltr, label: Text('Left → Right'), icon: Icon(Icons.format_textdirection_l_to_r), ), ButtonSegment( value: ReadingDirection.rtl, label: Text('Right → Left'), icon: Icon(Icons.format_textdirection_r_to_l), ), ], selected: {direction}, onSelectionChanged: (s) { ref.read(readingDirectionProvider.notifier).setDirection(s.first); }, ), const SizedBox(height: 24), Text('WebDAV sync', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), Text( 'Syncs reading progress, reader mode/direction, Library stars, ' 'and History across devices.', style: Theme.of(context).textTheme.bodySmall, ), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Enable sync'), value: _enabled, onChanged: (v) => setState(() => _enabled = v), ), TextField( controller: _urlCtrl, decoration: const InputDecoration( labelText: 'WebDAV URL', hintText: 'https://dav.example.com/remote.php/dav/files/user', ), keyboardType: TextInputType.url, autocorrect: false, ), TextField( controller: _userCtrl, decoration: const InputDecoration(labelText: 'Username'), autocorrect: false, ), TextField( controller: _passCtrl, decoration: const InputDecoration(labelText: 'Password'), obscureText: true, autocorrect: false, ), TextField( controller: _pathCtrl, decoration: const InputDecoration( labelText: 'Remote file path', hintText: WebDavSettings.defaultPath, ), autocorrect: false, ), const SizedBox(height: 12), if (_status != null) Text('Status: $_status', style: Theme.of(context).textTheme.bodySmall), if (_lastSync != null) Text( 'Last sync: ${_lastSync!.toLocal()}', style: Theme.of(context).textTheme.bodySmall, ), if (_error != null) Text( _error!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), const SizedBox(height: 12), Row( children: [ FilledButton( onPressed: _busy ? null : _test, child: const Text('Test connection'), ), const SizedBox(width: 12), OutlinedButton( onPressed: _busy || !_enabled ? null : _syncNow, child: const Text('Sync now'), ), ], ), const SizedBox(height: 32), Text( 'Notifications (ntfy)', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), Text( 'Send download events to ntfy topics. Leave a URL empty to disable that event.', style: Theme.of(context).textTheme.bodySmall, ), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Enable notifications'), value: _ntfyEnabled, onChanged: (v) => setState(() => _ntfyEnabled = v), ), _ntfyUrlRow( label: 'Chapter completed URL', controller: _ntfyCompletedCtrl, kind: 'completed', ), _ntfyUrlRow( label: 'Job failed URL', controller: _ntfyFailedCtrl, kind: 'failed', ), _ntfyUrlRow( label: 'Queue drained URL', controller: _ntfyDrainedCtrl, kind: 'drained', ), const SizedBox(height: 8), Text( 'Attachment on complete', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 8), DropdownMenu( initialSelection: _ntfyAttachment, enabled: _ntfyEnabled, width: double.infinity, dropdownMenuEntries: [ for (final m in NtfyAttachmentMode.values) DropdownMenuEntry(value: m, label: m.label), ], onSelected: !_ntfyEnabled ? null : (v) { if (v != null) setState(() => _ntfyAttachment = v); }, ), const SizedBox(height: 4), Text( 'Cover uses the series cover URL. First page is resized to ≤720px / ≤250 KB. CBZ skips if over 15 MB.', style: Theme.of(context).textTheme.bodySmall, ), TextField( controller: _ntfyTokenCtrl, decoration: const InputDecoration( labelText: 'Auth token (optional)', hintText: 'Bearer token for private topics', ), obscureText: true, autocorrect: false, enabled: _ntfyEnabled, ), if (_busy) ...[ const SizedBox(height: 16), const LinearProgressIndicator(), ], const SizedBox(height: 32), Text('About', style: Theme.of(context).textTheme.titleMedium), const ListTile( contentPadding: EdgeInsets.zero, title: Text('Version'), subtitle: Text('1.0'), ), ], ), ), ); } }