ing csv import
This commit is contained in:
@@ -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: [
|
||||
|
||||
+525
-504
File diff suppressed because it is too large
Load Diff
@@ -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,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: [
|
||||
|
||||
Reference in New Issue
Block a user