app working with sandbox

This commit is contained in:
davmar
2026-09-06 21:13:30 +02:00
parent 8475abeddb
commit 773187f0af
18 changed files with 1909 additions and 100 deletions
+12 -2
View File
@@ -1,9 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="MyApp" android:label="IngTrak"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -25,6 +26,15 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Enable Banking redirect: ingtrak://connected?code=... -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="ingtrak"
android:host="connected"/>
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
File diff suppressed because one or more lines are too long
+57 -26
View File
@@ -1,13 +1,15 @@
/// Application entry point: wires services, Provider, and [MaterialApp]. /// Application entry point: wires services, Provider, deep links, and [MaterialApp].
library; library;
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'providers/item_provider.dart'; import 'providers/bank_provider.dart';
import 'providers/theme_provider.dart'; import 'providers/theme_provider.dart';
import 'screens/splash_screen.dart'; import 'screens/splash_screen.dart';
import 'services/item_repository.dart';
import 'services/storage_service.dart'; import 'services/storage_service.dart';
import 'utils/app_theme.dart'; import 'utils/app_theme.dart';
import 'utils/constants.dart'; import 'utils/constants.dart';
@@ -18,37 +20,66 @@ Future<void> main() async {
final storage = StorageService(); final storage = StorageService();
await storage.init(); await storage.init();
runApp( runApp(MyApp(storage: storage));
MyApp( }
storage: storage,
repository: ItemRepository(useRemoteData: storage.useRemoteData), /// Root widget. Theme and bank controllers live here so every route
), /// can `context.watch` them.
class MyApp extends StatefulWidget {
const MyApp({super.key, required this.storage});
final StorageService storage;
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final BankController _bank;
late final AppLinks _appLinks;
StreamSubscription<Uri>? _linkSub;
@override
void initState() {
super.initState();
_bank = BankController()..refresh();
_appLinks = AppLinks();
_listenForBankRedirects();
}
Future<void> _listenForBankRedirects() async {
try {
final initial = await _appLinks.getInitialLink();
if (initial != null) {
await _bank.handleRedirect(initial);
}
} catch (error, stackTrace) {
debugPrint('Initial deep link failed: $error\n$stackTrace');
}
_linkSub = _appLinks.uriLinkStream.listen(
(uri) {
unawaited(_bank.handleRedirect(uri));
},
onError: (Object error, StackTrace stackTrace) {
debugPrint('Deep link stream error: $error\n$stackTrace');
},
); );
} }
/// Root widget. Theme and catalog controllers live here so every route @override
/// can `context.watch` them. void dispose() {
class MyApp extends StatelessWidget { _linkSub?.cancel();
const MyApp({ _bank.dispose();
super.key, super.dispose();
required this.storage, }
required this.repository,
});
final StorageService storage;
final ItemRepository repository;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
ChangeNotifierProvider(create: (_) => ThemeController(storage)), ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)),
ChangeNotifierProvider( ChangeNotifierProvider<BankController>.value(value: _bank),
create: (_) => ItemController(
repository: repository,
storage: storage,
)..loadItems(),
),
], ],
child: const _MaterialRoot(), child: const _MaterialRoot(),
); );
+112
View File
@@ -0,0 +1,112 @@
/// Domain models for the personal ingtrak bank-data API.
library;
/// Result of `POST /api/connect`.
class ConnectStart {
const ConnectStart({required this.authUrl, required this.state});
final String authUrl;
final String state;
factory ConnectStart.fromJson(Map<String, dynamic> json) {
return ConnectStart(
authUrl: json['authUrl'] as String? ?? '',
state: json['state'] as String? ?? '',
);
}
}
/// Link status from `/api/connect/status` or `/api/connect/complete`.
class ConnectStatus {
const ConnectStatus({
required this.status,
required this.linked,
this.accountId,
this.sessionId,
this.state,
this.validUntil,
});
final String status;
final bool linked;
final String? accountId;
final String? sessionId;
final String? state;
final String? validUntil;
factory ConnectStatus.fromJson(Map<String, dynamic> json) {
return ConnectStatus(
status: json['status'] as String? ?? 'unknown',
linked: json['linked'] as bool? ?? false,
accountId: json['accountId'] as String?,
sessionId: json['sessionId'] as String?,
state: json['state'] as String?,
validUntil: json['validUntil'] as String?,
);
}
static const disconnected = ConnectStatus(
status: 'disconnected',
linked: false,
);
}
/// `GET /api/balance` payload.
class BankBalance {
const BankBalance({
required this.amount,
required this.currency,
this.balanceType,
});
final double amount;
final String currency;
final String? balanceType;
factory BankBalance.fromJson(Map<String, dynamic> json) {
final rawAmount = json['amount'];
final amount = switch (rawAmount) {
num n => n.toDouble(),
String s => double.tryParse(s) ?? 0,
_ => 0.0,
};
return BankBalance(
amount: amount,
currency: json['currency'] as String? ?? 'EUR',
balanceType: json['balanceType'] as String?,
);
}
}
/// One row from `GET /api/transactions`.
class BankTransaction {
const BankTransaction({
required this.date,
required this.description,
required this.amount,
required this.currency,
required this.pending,
});
final String date;
final String description;
final double amount;
final String currency;
final bool pending;
factory BankTransaction.fromJson(Map<String, dynamic> json) {
final rawAmount = json['amount'];
final amount = switch (rawAmount) {
num n => n.toDouble(),
String s => double.tryParse(s) ?? 0,
_ => 0.0,
};
return BankTransaction(
date: json['date'] as String? ?? '',
description: json['description'] as String? ?? 'Unknown',
amount: amount,
currency: json['currency'] as String? ?? 'EUR',
pending: json['pending'] as bool? ?? false,
);
}
}
+180
View File
@@ -0,0 +1,180 @@
/// [ChangeNotifier] for bank link status, balance, and transactions.
library;
import 'package:flutter/foundation.dart';
import '../models/bank.dart';
import '../services/ingtrak_api.dart';
import '../utils/constants.dart';
/// App-wide banking state backed by the local ingtrak API.
class BankController extends ChangeNotifier {
BankController({IngtrakApi? api}) : _api = api ?? IngtrakApi();
final IngtrakApi _api;
ConnectStatus _status = ConnectStatus.disconnected;
BankBalance? _balance;
List<BankTransaction> _transactions = const [];
bool _isLoading = false;
bool _isConnecting = false;
String? _error;
String? _pendingState;
ConnectStatus get status => _status;
BankBalance? get balance => _balance;
List<BankTransaction> get transactions => List.unmodifiable(_transactions);
bool get isLoading => _isLoading;
bool get isConnecting => _isConnecting;
String? get error => _error;
bool get isLinked => _status.linked;
String get apiBaseUrl => _api.baseUrl;
String? get pendingState => _pendingState;
/// Load status; if linked, also load balance + transactions.
Future<void> refresh() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_status = await _api.fetchStatus();
if (_status.linked) {
await _loadLinkedData();
} else {
_balance = null;
_transactions = const [];
}
} on IngtrakApiException catch (error, stackTrace) {
_error = error.message;
debugPrint('BankController.refresh failed: $error\n$stackTrace');
} catch (error, stackTrace) {
_error = 'Could not reach $apiBaseUrl';
debugPrint('BankController.refresh failed: $error\n$stackTrace');
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> _loadLinkedData() async {
try {
final results = await Future.wait([
_api.fetchBalance(),
_api.fetchTransactions(),
]);
_balance = results[0] as BankBalance;
_transactions = results[1] as List<BankTransaction>;
_error = null;
} on IngtrakApiException catch (error) {
if (error.isNotLinked) {
_status = ConnectStatus.disconnected;
_balance = null;
_transactions = const [];
_error = error.message;
} else {
_error = error.message;
}
}
}
/// `POST /api/connect`. Returns auth payload for the in-app WebView.
Future<ConnectStart?> beginConnect() async {
_isConnecting = true;
_error = null;
notifyListeners();
try {
final start = await _api.startConnect();
if (start.authUrl.isEmpty) {
throw const IngtrakApiException('Connect response missing authUrl');
}
_pendingState = start.state;
return start;
} on IngtrakApiException catch (error, stackTrace) {
_error = error.message;
debugPrint('BankController.beginConnect failed: $error\n$stackTrace');
return null;
} catch (error, stackTrace) {
_error = 'Could not start bank connect.';
debugPrint('BankController.beginConnect failed: $error\n$stackTrace');
return null;
} finally {
_isConnecting = false;
notifyListeners();
}
}
/// Handle optional `ingtrak://connected?...` (e.g. after HTML bounce).
Future<bool> handleRedirect(Uri uri) async {
if (uri.scheme != kBankRedirectScheme) return false;
if (uri.host != kBankRedirectHost && uri.path != '/$kBankRedirectHost') {
if (uri.host.isNotEmpty && uri.host != kBankRedirectHost) return false;
}
final code = uri.queryParameters['code'];
if (code != null && code.isNotEmpty) {
final state = uri.queryParameters['state'] ?? _pendingState;
return completeWithCode(code: code, state: state);
}
// Backend already exchanged the code; just refresh local state.
if (uri.queryParameters['linked'] == '1') {
await refresh();
if (!isLinked) {
_error =
'Returned to the app, but the backend still has no linked session. '
'Finish login inside the in-app WebView (do not open the email '
'link in another browser), then pull to refresh.';
notifyListeners();
}
return isLinked;
}
final redirectError = uri.queryParameters['error'];
_error = redirectError != null
? 'Bank auth failed: $redirectError'
: 'Bank redirect missing code';
notifyListeners();
return true;
}
Future<bool> completeWithCode({
required String code,
String? state,
}) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_status = await _api.completeConnect(
code: code,
state: state ?? _pendingState,
);
_pendingState = null;
if (_status.linked) {
await _loadLinkedData();
}
return _status.linked;
} on IngtrakApiException catch (error, stackTrace) {
_error = error.message;
debugPrint('BankController.completeWithCode failed: $error\n$stackTrace');
return false;
} catch (error, stackTrace) {
_error = 'Could not complete bank connect.';
debugPrint('BankController.completeWithCode failed: $error\n$stackTrace');
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
@override
void dispose() {
_api.close();
super.dispose();
}
}
+172
View File
@@ -0,0 +1,172 @@
/// Balance tab: connect bank CTA, current balance, and refresh.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/bank_provider.dart';
import '../utils/connect_bank.dart';
import '../utils/helpers.dart';
import '../widgets/empty_state.dart';
import '../widgets/loading_view.dart';
import '../widgets/responsive_body.dart';
class BalanceScreen extends StatelessWidget {
const BalanceScreen({super.key});
@override
Widget build(BuildContext context) {
final bank = context.watch<BankController>();
return Scaffold(
appBar: AppBar(
title: const Text('Balance'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: bank.isLoading ? null : bank.refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: RefreshIndicator(
onRefresh: bank.refresh,
child: ResponsiveBody(
child: _BalanceBody(bank: bank),
),
),
);
}
}
class _BalanceBody extends StatelessWidget {
const _BalanceBody({required this.bank});
final BankController bank;
@override
Widget build(BuildContext context) {
if (bank.isLoading && bank.balance == null && !bank.isLinked) {
return const LoadingView(message: 'Checking bank link…');
}
if (!bank.isLinked) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
EmptyState(
icon: Icons.account_balance_outlined,
title: 'Bank not linked',
message: bank.error ??
'Connect opens a sandbox bank login in-app. '
'Stay in that WebView until it returns here. '
'You should then see a balance and transactions.',
actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank',
onAction: () => runConnectBank(context, bank),
),
],
);
}
if (bank.error != null && bank.balance == null) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
EmptyState(
icon: Icons.error_outline,
title: 'Could not load balance',
message: bank.error!,
actionLabel: 'Retry',
onAction: bank.refresh,
),
],
);
}
final balance = bank.balance;
final theme = Theme.of(context);
final colors = theme.colorScheme;
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 8, bottom: 32),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [colors.primary, colors.tertiary],
),
boxShadow: [
BoxShadow(
color: colors.primary.withValues(alpha: 0.28),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Current balance',
style: theme.textTheme.labelMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.8),
),
),
const SizedBox(height: 8),
Text(
balance == null
? '—'
: formatMoney(balance.amount, balance.currency),
style: theme.textTheme.headlineMedium?.copyWith(
color: colors.onPrimary,
),
),
if (balance?.balanceType != null) ...[
const SizedBox(height: 8),
Text(
balance!.balanceType!,
style: theme.textTheme.labelMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.75),
),
),
],
],
),
),
const SizedBox(height: 20),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
Icons.check_circle_outline,
color: colors.primary,
),
title: const Text('Linked'),
subtitle: Text(
bank.status.accountId == null
? bank.status.status
: 'Account ${bank.status.accountId}',
),
),
if (bank.error != null) ...[
const SizedBox(height: 8),
Text(
bank.error!,
style: theme.textTheme.bodyMedium?.copyWith(color: colors.error),
),
],
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: bank.isConnecting
? null
: () => runConnectBank(context, bank),
icon: const Icon(Icons.link),
label: Text(bank.isConnecting ? 'Opening…' : 'Reconnect bank'),
),
],
);
}
}
+124
View File
@@ -0,0 +1,124 @@
/// In-app browser for Enable Banking hosted auth.
///
/// Enable Banking only allows http(s) redirect URLs, so we keep
/// `http://localhost:8000/api/connect/callback` and capture `code` here
/// when the WebView navigates to that callback.
library;
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// Result popped from [BankAuthScreen] after bank login.
class BankAuthResult {
const BankAuthResult({this.code, this.state, this.error});
final String? code;
final String? state;
final String? error;
bool get hasCode => code != null && code!.isNotEmpty;
}
class BankAuthScreen extends StatefulWidget {
const BankAuthScreen({
super.key,
required this.authUrl,
this.pendingState,
});
final String authUrl;
final String? pendingState;
@override
State<BankAuthScreen> createState() => _BankAuthScreenState();
}
class _BankAuthScreenState extends State<BankAuthScreen> {
late final WebViewController _controller;
var _finishing = false;
var _loading = true;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
if (mounted) setState(() => _loading = true);
_handleUrl(url);
},
onPageFinished: (_) {
if (mounted) setState(() => _loading = false);
},
onNavigationRequest: (request) {
if (_handleUrl(request.url)) {
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onWebResourceError: (error) {
// Callback to localhost often fails to load on emulators; if we
// already captured the code, ignore the load error.
if (_finishing) return;
debugPrint('Bank auth WebView error: ${error.description}');
},
),
)
..loadRequest(Uri.parse(widget.authUrl));
}
/// Returns true when [url] is the Enable Banking redirect callback.
bool _handleUrl(String url) {
final uri = Uri.tryParse(url);
if (uri == null || _finishing) return false;
if (!_isConnectCallback(uri)) return false;
_finishing = true;
final error = uri.queryParameters['error'];
final code = uri.queryParameters['code'];
final state = uri.queryParameters['state'] ?? widget.pendingState;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
Navigator.of(context).pop(
BankAuthResult(
code: code,
state: state,
error: error,
),
);
});
return true;
}
bool _isConnectCallback(Uri uri) {
final path = uri.path;
return path == '/api/connect/callback' ||
path.endsWith('/api/connect/callback');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Connect bank'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_loading)
const Align(
alignment: Alignment.topCenter,
child: LinearProgressIndicator(minHeight: 2),
),
],
),
);
}
}
+12 -19
View File
@@ -1,14 +1,13 @@
/// Host scaffold that owns the bottom navigation bar and its four tabs. /// Host scaffold that owns the bottom navigation bar and its tabs.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'home_screen.dart'; import 'balance_screen.dart';
import 'profile_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'transactions_screen.dart';
/// Switches between Home, Search, Profile, and Settings without stacking routes. /// Switches between Balance, Transactions, and Settings without stacking routes.
class MainShell extends StatefulWidget { class MainShell extends StatefulWidget {
const MainShell({super.key}); const MainShell({super.key});
@@ -20,9 +19,8 @@ class _MainShellState extends State<MainShell> {
int _index = 0; int _index = 0;
static const _pages = [ static const _pages = [
HomeScreen(), BalanceScreen(),
SearchScreen(), TransactionsScreen(),
ProfileScreen(),
SettingsScreen(), SettingsScreen(),
]; ];
@@ -38,19 +36,14 @@ class _MainShellState extends State<MainShell> {
onDestinationSelected: (index) => setState(() => _index = index), onDestinationSelected: (index) => setState(() => _index = index),
destinations: const [ destinations: const [
NavigationDestination( NavigationDestination(
icon: Icon(Icons.home_outlined), icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.home), selectedIcon: Icon(Icons.account_balance_wallet),
label: 'Home', label: 'Balance',
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.search_outlined), icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.search), selectedIcon: Icon(Icons.receipt_long),
label: 'Search', label: 'Transactions',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profile',
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.settings_outlined), icon: Icon(Icons.settings_outlined),
+52 -12
View File
@@ -1,11 +1,12 @@
/// Settings tab: theme mode, data source, and about information. /// Settings tab: theme mode, API endpoint, and bank link status.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/item_provider.dart'; import '../providers/bank_provider.dart';
import '../providers/theme_provider.dart'; import '../providers/theme_provider.dart';
import '../utils/connect_bank.dart';
import '../utils/constants.dart'; import '../utils/constants.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
@@ -16,7 +17,7 @@ class SettingsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeController = context.watch<ThemeController>(); final themeController = context.watch<ThemeController>();
final items = context.watch<ItemController>(); final bank = context.watch<BankController>();
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
@@ -56,18 +57,57 @@ class SettingsScreen extends StatelessWidget {
}, },
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
Text('Data', style: theme.textTheme.titleMedium), Text('Backend', style: theme.textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
Card( Card(
child: SwitchListTile( child: Column(
title: const Text('Use remote sample API'), children: [
subtitle: const Text( ListTile(
'When on, items are fetched with the http package from ' leading: const Icon(Icons.dns_outlined),
'JSONPlaceholder. Failures fall back to local mock data.', title: const Text('API base URL'),
subtitle: Text(bank.apiBaseUrl),
), ),
value: items.useRemoteData, const Divider(height: 1),
onChanged: (value) => items.setUseRemoteData(value), 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),
const ListTile(
leading: Icon(Icons.open_in_browser_outlined),
title: 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 URL inside a WebView. '
'Override the API host with '
'`--dart-define=API_BASE_URL=http://<host>:8000`.',
style: theme.textTheme.bodyMedium,
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
Text('About', style: theme.textTheme.titleMedium), Text('About', style: theme.textTheme.titleMedium),
@@ -78,7 +118,7 @@ class SettingsScreen extends StatelessWidget {
const ListTile( const ListTile(
leading: Icon(Icons.apps), leading: Icon(Icons.apps),
title: Text(kAppName), title: Text(kAppName),
subtitle: Text('Generic Flutter Android starter'), subtitle: Text('Personal bank data via Enable Banking'),
), ),
const Divider(height: 1), const Divider(height: 1),
const ListTile( const ListTile(
+2 -2
View File
@@ -90,7 +90,7 @@ class _SplashScreenState extends State<SplashScreen>
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
), ),
child: Icon( child: Icon(
Icons.layers_rounded, Icons.account_balance_rounded,
size: 44, size: 44,
color: colors.onPrimary, color: colors.onPrimary,
), ),
@@ -104,7 +104,7 @@ class _SplashScreenState extends State<SplashScreen>
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'A Material 3 starter template', 'Your bank data, simply',
style: textTheme.bodyLarge?.copyWith( style: textTheme.bodyLarge?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.85), color: colors.onPrimary.withValues(alpha: 0.85),
), ),
+142
View File
@@ -0,0 +1,142 @@
/// Transactions tab: recent bank movements from the ingtrak API.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/bank.dart';
import '../providers/bank_provider.dart';
import '../utils/connect_bank.dart';
import '../utils/helpers.dart';
import '../widgets/empty_state.dart';
import '../widgets/loading_view.dart';
import '../widgets/responsive_body.dart';
class TransactionsScreen extends StatelessWidget {
const TransactionsScreen({super.key});
@override
Widget build(BuildContext context) {
final bank = context.watch<BankController>();
return Scaffold(
appBar: AppBar(
title: const Text('Transactions'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: bank.isLoading ? null : bank.refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: RefreshIndicator(
onRefresh: bank.refresh,
child: ResponsiveBody(
child: _TransactionsBody(bank: bank),
),
),
);
}
}
class _TransactionsBody extends StatelessWidget {
const _TransactionsBody({required this.bank});
final BankController bank;
@override
Widget build(BuildContext context) {
if (bank.isLoading && bank.transactions.isEmpty) {
return const LoadingView(message: 'Loading transactions…');
}
if (!bank.isLinked) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
EmptyState(
icon: Icons.receipt_long_outlined,
title: 'No transactions yet',
message: bank.error ??
'Link your bank on the Balance tab to load recent activity.',
actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank',
onAction: () => runConnectBank(context, bank),
),
],
);
}
if (bank.error != null && bank.transactions.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
EmptyState(
icon: Icons.error_outline,
title: 'Could not load transactions',
message: bank.error!,
actionLabel: 'Retry',
onAction: bank.refresh,
),
],
);
}
if (bank.transactions.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
EmptyState(
icon: Icons.inbox_outlined,
title: 'No recent activity',
message: 'The backend returned an empty transaction list.',
),
],
);
}
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 8, bottom: 32),
itemCount: bank.transactions.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _TransactionTile(tx: bank.transactions[index]);
},
);
}
}
class _TransactionTile extends StatelessWidget {
const _TransactionTile({required this.tx});
final BankTransaction tx;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final isCredit = tx.amount >= 0;
final amountColor = isCredit ? colors.primary : colors.error;
return Card(
child: ListTile(
title: Text(
tx.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
formatIsoDate(tx.date),
if (tx.pending) 'Pending',
].where((part) => part.isNotEmpty).join(' · '),
),
trailing: Text(
formatMoney(tx.amount, tx.currency),
style: theme.textTheme.titleMedium?.copyWith(color: amountColor),
),
),
);
}
}
+171
View File
@@ -0,0 +1,171 @@
/// Thin HTTP client for the personal ingtrak FastAPI backend.
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/bank.dart';
import '../utils/constants.dart';
/// Talks to Enable Banking via the local ingtrak API (no app-user auth).
class IngtrakApi {
IngtrakApi({http.Client? client, this.baseUrl = kApiBaseUrl})
: _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
Uri _uri(String path, [Map<String, String>? query]) {
final normalized = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl;
return Uri.parse('$normalized$path').replace(queryParameters: query);
}
Future<Map<String, dynamic>> _decodeObject(http.Response response) {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const IngtrakApiException('Unexpected response shape');
}
return Future.value(decoded);
}
void _ensureOk(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) return;
throw IngtrakApiException(
_messageFromBody(response) ??
'Request failed (${response.statusCode})',
statusCode: response.statusCode,
);
}
String? _messageFromBody(http.Response response) {
try {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final detail = decoded['detail'];
if (detail is String) return detail;
if (detail is Map) {
final nested = detail['enablebanking'];
if (nested is Map && nested['message'] is String) {
return nested['message'] as String;
}
if (detail['message'] is String) {
return detail['message'] as String;
}
}
}
} catch (_) {
// Fall through to generic message.
}
return null;
}
Future<bool> health() async {
final response = await _client.get(
_uri('/health'),
headers: const {'Accept': 'application/json'},
);
_ensureOk(response);
final body = await _decodeObject(response);
return body['status'] == 'ok';
}
/// `POST /api/connect` → open [ConnectStart.authUrl] in a browser.
Future<ConnectStart> startConnect({
String? aspspName,
String? aspspCountry,
}) async {
final payload = <String, String>{};
if (aspspName != null && aspspName.isNotEmpty) {
payload['aspspName'] = aspspName;
}
if (aspspCountry != null && aspspCountry.isNotEmpty) {
payload['aspspCountry'] = aspspCountry;
}
final response = await _client.post(
_uri('/api/connect'),
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode(payload),
);
_ensureOk(response);
return ConnectStart.fromJson(await _decodeObject(response));
}
/// `POST /api/connect/complete` after the deep-link redirect.
Future<ConnectStatus> completeConnect({
required String code,
String? state,
}) async {
final payload = <String, String>{'code': code};
if (state != null && state.isNotEmpty) {
payload['state'] = state;
}
final response = await _client.post(
_uri('/api/connect/complete'),
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode(payload),
);
_ensureOk(response);
return ConnectStatus.fromJson(await _decodeObject(response));
}
Future<ConnectStatus> fetchStatus() async {
final response = await _client.get(
_uri('/api/connect/status'),
headers: const {'Accept': 'application/json'},
);
_ensureOk(response);
return ConnectStatus.fromJson(await _decodeObject(response));
}
Future<BankBalance> fetchBalance() async {
final response = await _client.get(
_uri('/api/balance'),
headers: const {'Accept': 'application/json'},
);
_ensureOk(response);
return BankBalance.fromJson(await _decodeObject(response));
}
Future<List<BankTransaction>> fetchTransactions({int days = 90}) async {
final response = await _client.get(
_uri('/api/transactions', {'days': '$days'}),
headers: const {'Accept': 'application/json'},
);
_ensureOk(response);
final decoded = jsonDecode(response.body);
if (decoded is! List) {
throw const IngtrakApiException('Unexpected transactions response');
}
return decoded
.whereType<Map<String, dynamic>>()
.map(BankTransaction.fromJson)
.toList(growable: false);
}
void close() => _client.close();
}
class IngtrakApiException implements Exception {
const IngtrakApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
bool get isNotLinked => statusCode == 409;
@override
String toString() => message;
}
+59
View File
@@ -0,0 +1,59 @@
/// Shared UI helpers for bank connect actions.
library;
import 'package:flutter/material.dart';
import '../providers/bank_provider.dart';
import '../screens/bank_auth_screen.dart';
Future<void> runConnectBank(BuildContext context, BankController bank) async {
final start = await bank.beginConnect();
if (!context.mounted) return;
if (start == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(bank.error ?? 'Could not start bank connect')),
);
return;
}
final result = await Navigator.of(context).push<BankAuthResult>(
MaterialPageRoute(
builder: (_) => BankAuthScreen(
authUrl: start.authUrl,
pendingState: start.state,
),
),
);
if (!context.mounted) return;
if (result == null) return;
if (result.error != null && result.error!.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Bank auth failed: ${result.error}')),
);
return;
}
if (!result.hasCode) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Bank redirect missing code')),
);
return;
}
final linked = await bank.completeWithCode(
code: result.code!,
state: result.state,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
linked ? 'Bank linked' : (bank.error ?? 'Could not complete connect'),
),
),
);
}
+27 -1
View File
@@ -2,7 +2,7 @@
library; library;
/// Human-readable name shown in the splash screen, app bars, and About tile. /// Human-readable name shown in the splash screen, app bars, and About tile.
const String kAppName = 'MyApp'; const String kAppName = 'IngTrak';
/// Package / Android application id. /// Package / Android application id.
const String kPackageName = 'com.example.myapp'; const String kPackageName = 'com.example.myapp';
@@ -10,6 +10,32 @@ const String kPackageName = 'com.example.myapp';
/// How long the splash screen stays visible before navigating to the shell. /// How long the splash screen stays visible before navigating to the shell.
const Duration kSplashDuration = Duration(seconds: 2); const Duration kSplashDuration = Duration(seconds: 2);
/// Base URL for the personal ingtrak FastAPI backend.
///
/// Override at build/run time:
/// `flutter run --dart-define=API_BASE_URL=http://192.168.1.114:8000`
///
/// Defaults:
/// - Android emulator → `http://10.0.2.2:8000` (host loopback)
/// - iOS Simulator → `http://127.0.0.1:8000`
/// - Physical device → Mac LAN IP, e.g. `http://192.168.1.114:8000`
const String kApiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.0.2.2:8000',
);
/// Enable Banking only accepts http(s) redirect URLs (not custom schemes).
/// Keep this exact value in the Control Panel and backend `REDIRECT_URI`.
/// The app opens auth in a WebView and intercepts this callback to read `code`.
const String kEnableBankingRedirectUri =
'http://localhost:8000/api/connect/callback';
/// Optional app-scheme bounce used after the http callback (not registered
/// with Enable Banking).
const String kBankRedirectScheme = 'ingtrak';
const String kBankRedirectHost = 'connected';
const String kBankRedirectUri = '$kBankRedirectScheme://$kBankRedirectHost';
/// SharedPreferences keys. /// SharedPreferences keys.
abstract final class StorageKeys { abstract final class StorageKeys {
static const themeMode = 'theme_mode'; static const themeMode = 'theme_mode';
+28
View File
@@ -57,3 +57,31 @@ String labelForThemeMode(ThemeMode mode) {
ThemeMode.system => 'System', ThemeMode.system => 'System',
}; };
} }
/// Formats a money amount with currency code, e.g. `1 234,56 EUR`.
String formatMoney(double amount, String currency) {
final sign = amount < 0 ? '-' : '';
final abs = amount.abs();
final fixed = abs.toStringAsFixed(2);
final parts = fixed.split('.');
final whole = parts[0];
final fraction = parts.length > 1 ? parts[1] : '00';
final buffer = StringBuffer();
for (var i = 0; i < whole.length; i++) {
final fromEnd = whole.length - i;
buffer.write(whole[i]);
if (fromEnd > 1 && fromEnd % 3 == 1) {
buffer.write('\u00a0');
}
}
return '$sign${buffer.toString()},$fraction $currency';
}
/// Formats an ISO date string (`yyyy-MM-dd` or full ISO) for list rows.
String formatIsoDate(String raw) {
if (raw.isEmpty) return '';
final parsed = DateTime.tryParse(raw);
if (parsed == null) return raw;
return formatDate(parsed);
}
+72
View File
@@ -1,6 +1,38 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
app_links:
dependency: "direct main"
description:
name: app_links
sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea
url: "https://pub.dev"
source: hosted
version: "7.2.1"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -96,6 +128,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
gtk:
dependency: transitive
description:
name: gtk
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -373,6 +413,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
url: "https://pub.dev"
source: hosted
version: "4.14.1"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964"
url: "https://pub.dev"
source: hosted
version: "4.14.1"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730
url: "https://pub.dev"
source: hosted
version: "3.26.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
+2
View File
@@ -37,6 +37,8 @@ dependencies:
provider: ^6.1.5+1 # ChangeNotifier state for theme and catalog provider: ^6.1.5+1 # ChangeNotifier state for theme and catalog
http: ^1.6.0 # Placeholder network client (JSONPlaceholder) http: ^1.6.0 # Placeholder network client (JSONPlaceholder)
shared_preferences: ^2.5.5 # Persist theme mode and data-source toggle shared_preferences: ^2.5.5 # Persist theme mode and data-source toggle
app_links: ^7.2.1
webview_flutter: ^4.14.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+23 -35
View File
@@ -1,9 +1,9 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/main.dart'; import 'package:myapp/main.dart';
import 'package:myapp/models/item.dart'; import 'package:myapp/models/bank.dart';
import 'package:myapp/services/item_repository.dart';
import 'package:myapp/services/storage_service.dart'; import 'package:myapp/services/storage_service.dart';
import 'package:myapp/utils/constants.dart'; import 'package:myapp/utils/constants.dart';
import 'package:myapp/utils/helpers.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
@@ -13,15 +13,10 @@ void main() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
final storage = StorageService(); final storage = StorageService();
await storage.init(); await storage.init();
await tester.pumpWidget( await tester.pumpWidget(MyApp(storage: storage));
MyApp(
storage: storage,
repository: ItemRepository(),
),
);
} }
testWidgets('splash shows the app name then opens Home', (tester) async { testWidgets('splash shows the app name then opens Balance', (tester) async {
await pumpApp(tester); await pumpApp(tester);
expect(find.text(kAppName), findsWidgets); expect(find.text(kAppName), findsWidgets);
@@ -30,37 +25,30 @@ void main() {
await tester.pump(const Duration(milliseconds: 450)); await tester.pump(const Duration(milliseconds: 450));
await tester.pump(const Duration(milliseconds: 350)); await tester.pump(const Duration(milliseconds: 350));
expect(find.text('Home'), findsWidgets); expect(find.text('Balance'), findsWidgets);
expect(find.text('Featured items'), findsOneWidget); expect(find.text('Transactions'), findsOneWidget);
expect(find.text('Search'), findsOneWidget);
expect(find.text('Profile'), findsOneWidget);
expect(find.text('Settings'), findsOneWidget); expect(find.text('Settings'), findsOneWidget);
}); });
testWidgets('tapping a list card opens the detail screen', (tester) async { test('ConnectStart.fromJson maps authUrl and state', () {
await pumpApp(tester); final start = ConnectStart.fromJson({
await tester.pump(kSplashDuration); 'authUrl': 'https://example.test/auth',
await tester.pump(const Duration(milliseconds: 450)); 'state': 'abc',
await tester.pump(const Duration(milliseconds: 350)); });
expect(start.authUrl, 'https://example.test/auth');
await tester.tap(find.text('Material 3 surfaces')); expect(start.state, 'abc');
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('Details'), findsOneWidget);
expect(find.text('Back to list'), findsOneWidget);
}); });
test('Item.fromJson maps a JSONPlaceholder post', () { test('BankTransaction.fromJson maps API fields', () {
final item = Item.fromJson({ final tx = BankTransaction.fromJson({
'id': 3, 'date': '2026-09-01',
'title': 'hello world', 'description': 'Coffee',
'body': 'First sentence. Second sentence.', 'amount': -3.5,
'currency': 'EUR',
'pending': false,
}); });
expect(tx.description, 'Coffee');
expect(item.id, 'remote-3'); expect(tx.amount, -3.5);
expect(item.title, 'Hello world'); expect(formatMoney(tx.amount, tx.currency), contains('EUR'));
expect(item.subtitle, 'First sentence.');
expect(item.category, ItemCategory.lifestyle);
}); });
} }