Files
ingtrakapp/lib/screens/settings_screen.dart
2026-09-10 10:51:45 +02:00

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),
],
),
),
);
}
}