ing csv import

This commit is contained in:
davmar
2026-09-08 12:58:32 +02:00
parent 21df7b0085
commit e7a7b1a357
14 changed files with 1271 additions and 541 deletions
+3 -3
View File
@@ -54,11 +54,11 @@ class _HomeBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (bank.isLoading && bank.balance == null && !bank.isLinked) {
if (bank.isLoading && bank.balance == null && !bank.hasData) {
return const LoadingView(message: 'Checking bank link…');
}
if (!bank.isLinked) {
if (!bank.isLinked && !bank.hasData) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
@@ -74,7 +74,7 @@ class _HomeBody extends StatelessWidget {
);
}
if (bank.error != null && bank.balance == null) {
if (bank.error != null && bank.balance == null && !bank.hasData) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
File diff suppressed because it is too large Load Diff
+134 -1
View File
@@ -1,6 +1,9 @@
/// Settings tab: theme mode, API endpoint, and bank link status.
/// 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';
@@ -14,6 +17,95 @@ import '../widgets/responsive_body.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>();
@@ -109,6 +201,47 @@ class SettingsScreen extends StatelessWidget {
style: theme.textTheme.bodyMedium,
),
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(
+19 -3
View File
@@ -19,7 +19,10 @@ import '../widgets/tx_heatmap.dart';
import 'transaction_detail_screen.dart';
class TransactionsScreen extends StatefulWidget {
const TransactionsScreen({super.key});
const TransactionsScreen({super.key, this.initialMonth});
/// When set, opens on this calendar month instead of the current month.
final DateTime? initialMonth;
@override
State<TransactionsScreen> createState() => _TransactionsScreenState();
@@ -28,11 +31,24 @@ class TransactionsScreen extends StatefulWidget {
class _TransactionsScreenState extends State<TransactionsScreen> {
final _searchController = TextEditingController();
String _query = '';
DateTime _month = monthStart(DateTime.now());
late DateTime _month;
DateTime? _selectedDay;
var _searchInMonthOnly = false;
final Set<String> _labelFilters = {};
@override
void initState() {
super.initState();
final initial = widget.initialMonth;
final now = monthStart(DateTime.now());
if (initial == null) {
_month = now;
} else {
final start = monthStart(initial);
_month = start.isAfter(now) ? now : start;
}
}
@override
void dispose() {
_searchController.dispose();
@@ -172,7 +188,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
return const LoadingView(message: 'Loading transactions…');
}
if (!bank.isLinked) {
if (!bank.isLinked && bank.transactions.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [