import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_localizations.dart'; import '../models/pen_tool.dart'; import '../models/pressure_curve.dart'; import '../providers/settings_provider.dart'; import '../screens/app_shell.dart' show exportDiagnosticPack; import '../services/badnote_server_client.dart'; import '../services/vault_service.dart'; import '../services/webdav_sync_service.dart'; import '../utils/stroke_stabilizer.dart'; /// Material 3 settings screen for BadNote. class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key, this.embeddedInShell = false}); final bool embeddedInShell; void _showColorPicker( BuildContext context, Color current, ValueChanged onPicked, ) { Color pickerColor = current; showDialog( context: context, builder: (context) { final l = AppLocalizations.of(context); return AlertDialog( title: Text(l.pickColor), content: SingleChildScrollView( child: ColorPicker( pickerColor: pickerColor, onColorChanged: (color) => pickerColor = color, ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l.cancel), ), TextButton( onPressed: () { onPicked(pickerColor); Navigator.of(context).pop(); }, child: Text(l.ok), ), ], ); }, ); } void _confirmClearData(BuildContext context, WidgetRef ref) { showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(AppLocalizations.of(ctx).clearSettingsTitle), content: Text(AppLocalizations.of(ctx).settingsClearConfirmBody), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(AppLocalizations.of(ctx).cancel), ), TextButton( onPressed: () { ref.read(settingsProvider).clearAllData(); Navigator.pop(ctx); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).settingsReset)), ); }, child: Text(AppLocalizations.of(ctx).clear), ), ], ), ); } @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); final colorScheme = Theme.of(context).colorScheme; return Scaffold( appBar: embeddedInShell ? AppBar(title: Text(AppLocalizations.of(context).settings)) : AppBar(title: Text(AppLocalizations.of(context).settings)), body: ListView( children: [ _SectionHeader( title: AppLocalizations.of(context).diagnosticsSection, icon: Icons.bug_report_outlined, ), ListTile( title: Text(AppLocalizations.of(context).diagnosticsExport), subtitle: Text(AppLocalizations.of(context).diagnosticsExportHint), trailing: const Icon(Icons.ios_share), onTap: () => exportDiagnosticPack(context), ), const Divider(), _SectionHeader( title: AppLocalizations.of(context).settingsDefaults, icon: Icons.tune, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppLocalizations.of(context).settingsDefaultTool, style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), DropdownButtonFormField( initialValue: settings.defaultTool, decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, ), items: PenTool.values.map((tool) { return DropdownMenuItem( value: tool, child: Text(tool.name), ); }).toList(), onChanged: (tool) { if (tool != null) settings.setDefaultTool(tool); }, ), const SizedBox(height: 16), Text( AppLocalizations.of(context).settingsDefaultColor, style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Row( children: [ MouseRegion( cursor: SystemMouseCursors.click, child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () => _showColorPicker( context, settings.defaultColor, settings.setDefaultColor, ), child: Container( width: 40, height: 40, decoration: BoxDecoration( color: settings.defaultColor, borderRadius: BorderRadius.circular(8), border: Border.all(color: colorScheme.outline), ), ), ), ), const SizedBox(width: 12), Text( '#${settings.defaultColor.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase()}', style: const TextStyle(fontFamily: 'monospace'), ), ], ), const SizedBox(height: 16), Text( AppLocalizations.of(context).settingsDefaultWidth, style: const TextStyle(fontWeight: FontWeight.w500), ), Slider( value: settings.defaultStrokeWidth, min: 1.0, max: 20.0, divisions: 19, label: settings.defaultStrokeWidth.toStringAsFixed(1), onChanged: settings.setDefaultStrokeWidth, ), const SizedBox(height: 16), Text( AppLocalizations.of(context).settingsPressureCurve, style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), DropdownButtonFormField( initialValue: settings.defaultPressureCurve, decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, ), items: PressureCurveType.values.map((curve) { return DropdownMenuItem( value: curve, child: Text(curve.name), ); }).toList(), onChanged: (curve) { if (curve != null) { settings.setDefaultPressureCurve(curve); } }, ), const SizedBox(height: 16), const Text( 'Stabilization', style: TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), DropdownButtonFormField( initialValue: settings.defaultStabilization, decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, ), items: StabilizationLevel.values.map((level) { return DropdownMenuItem( value: level, child: Text(level.name), ); }).toList(), onChanged: (level) { if (level != null) settings.setDefaultStabilization(level); }, ), ], ), ), const Divider(), _SectionHeader( title: AppLocalizations.of(context).settingsAppearance, icon: Icons.palette, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppLocalizations.of(context).settingsAppearance, style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), SegmentedButton( segments: [ ButtonSegment( value: ThemeMode.system, label: Text(AppLocalizations.of(context).themeSystem), icon: const Icon(Icons.brightness_auto), ), ButtonSegment( value: ThemeMode.light, label: Text(AppLocalizations.of(context).themeLight), icon: const Icon(Icons.light_mode), ), ButtonSegment( value: ThemeMode.dark, label: Text(AppLocalizations.of(context).themeDark), icon: const Icon(Icons.dark_mode), ), ], selected: {settings.themeMode}, onSelectionChanged: (modes) { settings.setThemeMode(modes.first); }, ), const SizedBox(height: 16), Text( AppLocalizations.of(context).seedColorDesc, style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Row( children: [ MouseRegion( cursor: SystemMouseCursors.click, child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () => _showColorPicker( context, settings.colorSchemeSeed, settings.setColorSchemeSeed, ), child: Container( width: 40, height: 40, decoration: BoxDecoration( color: settings.colorSchemeSeed, borderRadius: BorderRadius.circular(8), border: Border.all(color: colorScheme.outline), ), ), ), ), const SizedBox(width: 12), Text(AppLocalizations.of(context).seedColorDesc), ], ), ], ), ), const Divider(), _SectionHeader( title: AppLocalizations.of(context).vaultSection, icon: Icons.folder_special, ), const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: _VaultSettings(), ), const Divider(), _SectionHeader( title: AppLocalizations.of(context).syncSection, icon: Icons.cloud_sync, ), const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: _SyncSettings(), ), const Divider(), _SectionHeader( title: AppLocalizations.of(context).serverSection, icon: Icons.dns_outlined, ), const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: _ServerSettings(), ), const Divider(), _SectionHeader( title: AppLocalizations.of(context).settingsAbout, icon: Icons.info, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'BadNote v0.1.0', style: TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Text( 'Local-first Surface Pen note-taking with PDF/PPT annotation. ' 'OCR and search run entirely on your device.', style: TextStyle( fontSize: 13, color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 16), OutlinedButton.icon( onPressed: () => _confirmClearData(context, ref), icon: const Icon(Icons.delete_forever, color: Colors.red), label: const Text( 'Clear All Local Settings', style: TextStyle(color: Colors.red), ), ), ], ), ), const SizedBox(height: 32), ], ), ); } } /// Shows the current vault folder path and lets the user re-pick it. Re-uses /// the same `getDirectoryPath` flow as the first-run [VaultSetupScreen], /// persisting the choice through [VaultService.setVaultRoot]. class _VaultSettings extends StatefulWidget { const _VaultSettings(); @override State<_VaultSettings> createState() => _VaultSettingsState(); } class _VaultSettingsState extends State<_VaultSettings> { VaultService? _vault; String? _path; bool _busy = false; @override void initState() { super.initState(); _load(); } Future _load() async { final vault = await VaultService.getInstance(); if (!mounted) return; setState(() { _vault = vault; _path = vault.vaultRoot; }); } Future _changeFolder() async { final vault = _vault; if (vault == null) return; final l = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); setState(() => _busy = true); try { final path = await FilePicker.platform.getDirectoryPath( dialogTitle: l.vaultSetupTitle, lockParentWindow: true, ); if (path != null) { await vault.setVaultRoot(path); if (!mounted) return; setState(() => _path = path); messenger.showSnackBar(SnackBar(content: Text(l.vaultUpdated))); } } catch (e) { if (!mounted) return; messenger.showSnackBar( SnackBar(content: Text(l.vaultPickFailed(e.toString()))), ); } finally { if (mounted) setState(() => _busy = false); } } @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.vaultFolderLabel, style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Text( (_path == null || _path!.isEmpty) ? l.vaultNoneSelected : _path!, style: TextStyle( fontSize: 13, color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _busy ? null : _changeFolder, icon: const Icon(Icons.drive_folder_upload), label: Text(l.vaultChangeFolder), ), ], ); } } /// WebDAV sync configuration + actions. Persists config through /// [WebDavSyncService] (SharedPreferences-backed), exposes Test connection / /// Sync now buttons, the last-synced time and last result, and an /// "auto-sync on launch" toggle (default OFF). All network ops are /// time-bounded inside the service and surface friendly errors here. class _SyncSettings extends StatefulWidget { const _SyncSettings(); @override State<_SyncSettings> createState() => _SyncSettingsState(); } class _SyncSettingsState extends State<_SyncSettings> { WebDavSyncService? _sync; final _urlCtrl = TextEditingController(); final _userCtrl = TextEditingController(); final _passCtrl = TextEditingController(); final _folderCtrl = TextEditingController(); bool _autoSync = false; bool _busy = false; bool _testing = false; bool _obscure = true; DateTime? _lastSync; SyncResult? _lastResult; @override void initState() { super.initState(); _load(); } @override void dispose() { _urlCtrl.dispose(); _userCtrl.dispose(); _passCtrl.dispose(); _folderCtrl.dispose(); super.dispose(); } Future _load() async { final prefs = await SharedPreferences.getInstance(); final sync = WebDavSyncService(prefs); if (!mounted) return; final c = sync.config; setState(() { _sync = sync; _urlCtrl.text = c.baseUrl; _userCtrl.text = c.username; _passCtrl.text = c.password; _folderCtrl.text = c.remoteRoot; _autoSync = c.autoSync; _lastSync = sync.lastSyncTime; }); } WebDavConfig _currentConfig() => WebDavConfig( baseUrl: _urlCtrl.text, username: _userCtrl.text, password: _passCtrl.text, remoteRoot: _folderCtrl.text, autoSync: _autoSync, ); Future _save() async { final sync = _sync; if (sync == null) return; final l = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); await sync.saveConfig(_currentConfig()); if (!mounted) return; messenger.showSnackBar(SnackBar(content: Text(l.syncSaved))); } Future _testConnection() async { final sync = _sync; if (sync == null) return; final l = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); await sync.saveConfig(_currentConfig()); setState(() => _testing = true); final client = sync.buildClient(); try { if (client == null) { messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured))); return; } await client.testConnection(); if (!mounted) return; messenger.showSnackBar(SnackBar(content: Text(l.syncTestOk))); } catch (e) { if (!mounted) return; messenger .showSnackBar(SnackBar(content: Text(l.syncTestFailed(e.toString())))); } finally { client?.close(); if (mounted) setState(() => _testing = false); } } Future _syncNow() async { final sync = _sync; if (sync == null) return; final l = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); await sync.saveConfig(_currentConfig()); final vault = await VaultService.getInstance(); final root = vault.vaultRoot; if (root == null || root.isEmpty) { messenger.showSnackBar(SnackBar(content: Text(l.vaultNoneSelected))); return; } setState(() => _busy = true); final client = sync.buildClient(); try { if (client == null) { messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured))); return; } final result = await sync.syncNow(vaultRoot: root, client: client); if (!mounted) return; setState(() { _lastResult = result; _lastSync = sync.lastSyncTime; }); messenger.showSnackBar(SnackBar( content: Text(result.ok ? l.syncResultSummary( result.uploaded, result.downloaded, result.conflicts) : l.syncFailed(result.error ?? '')), )); } finally { client?.close(); if (mounted) setState(() => _busy = false); } } @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final configured = _urlCtrl.text.trim().isNotEmpty; final lastResult = _lastResult; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _urlCtrl, keyboardType: TextInputType.url, autocorrect: false, decoration: InputDecoration( labelText: l.syncServerUrl, hintText: l.syncServerUrlHint, border: const OutlineInputBorder(), isDense: true, ), onChanged: (_) => setState(() {}), ), const SizedBox(height: 12), TextField( controller: _userCtrl, autocorrect: false, decoration: InputDecoration( labelText: l.syncUsername, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: 12), TextField( controller: _passCtrl, obscureText: _obscure, autocorrect: false, decoration: InputDecoration( labelText: l.syncPassword, border: const OutlineInputBorder(), isDense: true, suffixIcon: IconButton( icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off), onPressed: () => setState(() => _obscure = !_obscure), ), ), ), const SizedBox(height: 12), TextField( controller: _folderCtrl, autocorrect: false, decoration: InputDecoration( labelText: l.syncRemoteFolder, hintText: l.syncRemoteFolderHint, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: 8), Text( l.syncCredentialsNote, style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(l.syncAuto), value: _autoSync, onChanged: (v) { setState(() => _autoSync = v); _save(); }, ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ OutlinedButton.icon( onPressed: (_busy || _testing) ? null : _save, icon: const Icon(Icons.save), label: Text(l.syncSave), ), OutlinedButton.icon( onPressed: (!configured || _busy || _testing) ? null : _testConnection, icon: _testing ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.wifi_tethering), label: Text(l.syncTestConnection), ), FilledButton.icon( onPressed: (!configured || _busy || _testing) ? null : _syncNow, icon: _busy ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.sync), label: Text(_busy ? l.syncRunning : l.syncNow), ), ], ), const SizedBox(height: 12), Text( _lastSync == null ? l.syncNeverRun : l.syncLastRun(_lastSync!.toLocal().toString()), style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant), ), if (lastResult != null && lastResult.ok) ...[ const SizedBox(height: 4), Text( l.syncResultSummary(lastResult.uploaded, lastResult.downloaded, lastResult.conflicts), style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant), ), ], ], ); } } class _ServerSettings extends StatefulWidget { const _ServerSettings(); @override State<_ServerSettings> createState() => _ServerSettingsState(); } class _ServerSettingsState extends State<_ServerSettings> { final _urlCtrl = TextEditingController(); final _userCtrl = TextEditingController(); final _passCtrl = TextEditingController(); bool _busy = false; bool _loggedIn = false; String? _status; @override void initState() { super.initState(); _load(); } @override void dispose() { _urlCtrl.dispose(); _userCtrl.dispose(); _passCtrl.dispose(); super.dispose(); } Future _load() async { final prefs = await SharedPreferences.getInstance(); final cfg = await BadNoteServerConfig.load(prefs); if (!mounted) return; setState(() { _urlCtrl.text = cfg.baseUrl; _userCtrl.text = cfg.username; _passCtrl.text = cfg.password; _loggedIn = cfg.isLoggedIn; }); } Future _saveAndLogin() async { final l = AppLocalizations.of(context); setState(() => _busy = true); try { final prefs = await SharedPreferences.getInstance(); var cfg = BadNoteServerConfig( baseUrl: _urlCtrl.text.trim(), username: _userCtrl.text.trim(), password: _passCtrl.text, ); final client = BadNoteServerClient(cfg); cfg = await client.registerOrLogin( username: cfg.username, password: cfg.password, ); await cfg.save(prefs); client.close(); if (!mounted) return; setState(() { _loggedIn = true; _status = l.serverLoggedIn; }); } catch (e) { if (!mounted) return; setState(() => _status = l.serverTestFail('$e')); } finally { if (mounted) setState(() => _busy = false); } } Future _test() async { final l = AppLocalizations.of(context); setState(() => _busy = true); try { final cfg = BadNoteServerConfig(baseUrl: _urlCtrl.text.trim()); final client = BadNoteServerClient(cfg); final health = await client.health(); client.close(); if (!mounted) return; setState(() { _status = l.serverTestOk('${health['version'] ?? health['api']}'); }); } catch (e) { if (!mounted) return; setState(() => _status = l.serverTestFail('$e')); } finally { if (mounted) setState(() => _busy = false); } } @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.serverHint, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), TextField( controller: _urlCtrl, decoration: InputDecoration( labelText: l.serverUrl, hintText: l.serverUrlHint, border: const OutlineInputBorder(), isDense: true, ), keyboardType: TextInputType.url, ), const SizedBox(height: 8), TextField( controller: _userCtrl, decoration: InputDecoration( labelText: l.serverUsername, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: 8), TextField( controller: _passCtrl, obscureText: true, decoration: InputDecoration( labelText: l.serverPassword, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton( onPressed: _busy ? null : _saveAndLogin, child: Text(l.serverSave), ), OutlinedButton( onPressed: _busy ? null : _test, child: Text(l.serverTest), ), if (_loggedIn) Chip( avatar: const Icon(Icons.check_circle, size: 16), label: Text(l.serverLoggedIn), ), ], ), if (_status != null) ...[ const SizedBox(height: 8), Text(_status!, style: Theme.of(context).textTheme.bodySmall), ], ], ); } } class _SectionHeader extends StatelessWidget { final String title; final IconData icon; const _SectionHeader({required this.title, required this.icon}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), child: Row( children: [ Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), const SizedBox(width: 8), Text( title, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ], ), ); } }