/// Settings tab: theme mode, API endpoint, bank link, and CSV import. library; import 'dart:convert'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/bank_provider.dart'; import '../providers/preferences_provider.dart'; import '../providers/theme_provider.dart'; import '../utils/connect_bank.dart'; import '../utils/constants.dart'; import '../utils/helpers.dart'; import '../widgets/responsive_body.dart'; import 'labels_screen.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); Future _importCsv(BuildContext context, BankController bank) async { // Use FileType.any — Android often greys out real ING exports when // filtering to csv/txt (odd MIME types / no extension / Downloads). final picked = await FilePicker.platform.pickFiles( type: FileType.any, withData: true, allowMultiple: false, ); if (picked == null || picked.files.isEmpty) return; final file = picked.files.single; var bytes = file.bytes; if ((bytes == null || bytes.isEmpty) && file.path != null) { bytes = await file.xFile.readAsBytes(); } if (bytes == null || bytes.isEmpty) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not read the selected file')), ); return; } String raw; try { raw = utf8.decode(bytes); } catch (_) { raw = latin1.decode(bytes); } try { final result = await bank.importIngCsv(raw); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( result.added.isEmpty ? 'No new transactions ' '(${result.skippedDuplicates} already in the app)' : 'Imported ${result.added.length} new' '${result.skippedDuplicates > 0 ? ' · skipped ${result.skippedDuplicates} duplicates' : ''}' ' of ${result.parsed} CSV rows', ), ), ); } on FormatException catch (error) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(error.message)), ); } catch (_) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(bank.error ?? 'Import failed')), ); } } Future _clearImports(BuildContext context, BankController bank) async { final ok = await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Clear imported CSV?'), content: Text( 'Removes ${bank.importedCount} locally imported transactions. ' 'Bank-linked data is kept.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('Clear'), ), ], ); }, ); if (ok != true || !context.mounted) return; await bank.clearImportedTransactions(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Imported transactions cleared')), ); } @override Widget build(BuildContext context) { final themeController = context.watch(); final prefs = context.watch(); final bank = context.watch(); final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: const Text('Settings')), body: ResponsiveBody( child: ListView( children: [ const SizedBox(height: 8), Text('Appearance', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Text( 'Theme is saved with shared_preferences and restored on launch.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 16), SegmentedButton( segments: const [ ButtonSegment( value: ThemeMode.system, label: Text('System'), icon: Icon(Icons.brightness_auto), ), ButtonSegment( value: ThemeMode.light, label: Text('Light'), icon: Icon(Icons.light_mode_outlined), ), ButtonSegment( value: ThemeMode.dark, label: Text('Dark'), icon: Icon(Icons.dark_mode_outlined), ), ], selected: {themeController.themeMode}, onSelectionChanged: (selection) { themeController.setThemeMode(selection.first); }, ), const SizedBox(height: 28), Text('Look ahead', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Card( child: ListTile( leading: const Icon(Icons.event_repeat_outlined), title: const Text('Projection horizon'), subtitle: Text( 'Navigate up to ${prefs.lookAheadMaxYears} year' '${prefs.lookAheadMaxYears == 1 ? '' : 's'} ahead', ), trailing: DropdownButton( value: prefs.lookAheadMaxYears, underline: const SizedBox.shrink(), items: [ for (final y in const [1, 2, 3, 5, 7, 10, 15, 20, 30]) DropdownMenuItem(value: y, child: Text('$y')), ], onChanged: (value) { if (value == null) return; prefs.setLookAheadMaxYears(value); }, ), ), ), const SizedBox(height: 28), Text('Backend', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Card( child: Column( children: [ ListTile( leading: const Icon(Icons.dns_outlined), title: const Text('API base URL'), subtitle: Text(bank.apiBaseUrl), ), const Divider(height: 1), ListTile( leading: Icon( bank.isLinked ? Icons.link : Icons.link_off_outlined, ), title: const Text('Bank link'), subtitle: Text( bank.isLinked ? 'Linked · ${bank.status.status}' : 'Not linked', ), trailing: bank.isLinked ? null : TextButton( onPressed: bank.isConnecting ? null : () => runConnectBank(context, bank), child: Text( bank.isConnecting ? 'Opening…' : 'Connect', ), ), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.open_in_browser_outlined), title: const Text('Enable Banking redirect'), subtitle: Text(kEnableBankingRedirectUri), ), ], ), ), const SizedBox(height: 12), Text( 'Enable Banking only allows http(s) redirects. Keep ' '$kEnableBankingRedirectUri in the Control Panel and backend ' 'REDIRECT_URI. The app captures that callback path in a WebView. ' 'Override with `--dart-define=API_BASE_URL=https://…`.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 28), Text('Labels', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Card( child: ListTile( leading: const Icon(Icons.label_outline), title: const Text('Manage labels'), subtitle: const Text('Create, nest, move, or delete'), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( MaterialPageRoute( builder: (_) => const LabelsScreen(), ), ); }, ), ), const SizedBox(height: 28), Text('CSV import', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Card( child: Column( children: [ ListTile( leading: const Icon(Icons.upload_file_outlined), title: const Text('Import ING CSV'), subtitle: Text( bank.isImporting ? 'Importing…' : bank.importedCount == 0 ? 'Adds older history without duplicating bank feed' : '${bank.importedCount} imported rows kept locally', ), trailing: TextButton( onPressed: bank.isImporting ? null : () => _importCsv(context, bank), child: Text(bank.isImporting ? '…' : 'Import'), ), ), if (bank.importedCount > 0) ...[ const Divider(height: 1), ListTile( leading: const Icon(Icons.delete_outline), title: const Text('Clear imported CSV'), onTap: () => _clearImports(context, bank), ), ], ], ), ), const SizedBox(height: 8), Text( 'Supports ING Netherlands exports (Dutch or English headers, ' 'comma or semicolon). Matching uses date + amount + merchant, ' 'so overlaps with the live feed are skipped.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 28), Text('About', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Card( child: Column( children: [ const ListTile( leading: Icon(Icons.apps), title: Text(kAppName), subtitle: Text('Personal bank data via Enable Banking'), ), const Divider(height: 1), const ListTile( leading: Icon(Icons.fingerprint), title: Text('Package'), subtitle: Text(kPackageName), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.contrast), title: const Text('Active theme'), subtitle: Text(labelForThemeMode(themeController.themeMode)), ), ], ), ), const SizedBox(height: 24), ], ), ), ); } }