327 lines
12 KiB
Dart
327 lines
12 KiB
Dart
/// 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<void> _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<void> _clearImports(BuildContext context, BankController bank) async {
|
|
final ok = await showDialog<bool>(
|
|
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<ThemeController>();
|
|
final prefs = context.watch<PreferencesController>();
|
|
final bank = context.watch<BankController>();
|
|
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<ThemeMode>(
|
|
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<int>(
|
|
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('Bank', style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Card(
|
|
child: ListTile(
|
|
leading: Icon(
|
|
bank.isLinked ? Icons.link : Icons.link_off_outlined,
|
|
),
|
|
title: const Text('Bank link'),
|
|
subtitle: Text(
|
|
bank.isLinked
|
|
? 'Linked · ${bank.status.status}'
|
|
: bank.hasData
|
|
? 'Not linked · ${bank.transactions.length} synced txs on device'
|
|
: 'Not linked · connect to fetch live balance and feed',
|
|
),
|
|
trailing: bank.isLinked
|
|
? null
|
|
: TextButton(
|
|
onPressed: bank.isConnecting
|
|
? null
|
|
: () => runConnectBank(context, bank),
|
|
child: Text(
|
|
bank.isConnecting ? 'Opening…' : 'Connect',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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: 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<void>(
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|