app working with sandbox
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="MyApp"
|
||||
android:label="IngTrak"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -25,6 +26,15 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</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>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
File diff suppressed because one or more lines are too long
+55
-24
@@ -1,13 +1,15 @@
|
||||
/// Application entry point: wires services, Provider, and [MaterialApp].
|
||||
/// Application entry point: wires services, Provider, deep links, and [MaterialApp].
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'providers/item_provider.dart';
|
||||
import 'providers/bank_provider.dart';
|
||||
import 'providers/theme_provider.dart';
|
||||
import 'screens/splash_screen.dart';
|
||||
import 'services/item_repository.dart';
|
||||
import 'services/storage_service.dart';
|
||||
import 'utils/app_theme.dart';
|
||||
import 'utils/constants.dart';
|
||||
@@ -18,37 +20,66 @@ Future<void> main() async {
|
||||
final storage = StorageService();
|
||||
await storage.init();
|
||||
|
||||
runApp(
|
||||
MyApp(
|
||||
storage: storage,
|
||||
repository: ItemRepository(useRemoteData: storage.useRemoteData),
|
||||
),
|
||||
);
|
||||
runApp(MyApp(storage: storage));
|
||||
}
|
||||
|
||||
/// Root widget. Theme and catalog controllers live here so every route
|
||||
/// Root widget. Theme and bank controllers live here so every route
|
||||
/// can `context.watch` them.
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({
|
||||
super.key,
|
||||
required this.storage,
|
||||
required this.repository,
|
||||
});
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({super.key, required this.storage});
|
||||
|
||||
final StorageService storage;
|
||||
final ItemRepository repository;
|
||||
|
||||
@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');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_linkSub?.cancel();
|
||||
_bank.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => ItemController(
|
||||
repository: repository,
|
||||
storage: storage,
|
||||
)..loadItems(),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)),
|
||||
ChangeNotifierProvider<BankController>.value(value: _bank),
|
||||
],
|
||||
child: const _MaterialRoot(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'home_screen.dart';
|
||||
import 'profile_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'balance_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 {
|
||||
const MainShell({super.key});
|
||||
|
||||
@@ -20,9 +19,8 @@ class _MainShellState extends State<MainShell> {
|
||||
int _index = 0;
|
||||
|
||||
static const _pages = [
|
||||
HomeScreen(),
|
||||
SearchScreen(),
|
||||
ProfileScreen(),
|
||||
BalanceScreen(),
|
||||
TransactionsScreen(),
|
||||
SettingsScreen(),
|
||||
];
|
||||
|
||||
@@ -38,19 +36,14 @@ class _MainShellState extends State<MainShell> {
|
||||
onDestinationSelected: (index) => setState(() => _index = index),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
icon: Icon(Icons.account_balance_wallet_outlined),
|
||||
selectedIcon: Icon(Icons.account_balance_wallet),
|
||||
label: 'Balance',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.search_outlined),
|
||||
selectedIcon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: 'Profile',
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
selectedIcon: Icon(Icons.receipt_long),
|
||||
label: 'Transactions',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/// Settings tab: theme mode, data source, and about information.
|
||||
/// Settings tab: theme mode, API endpoint, and bank link status.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/item_provider.dart';
|
||||
import '../providers/bank_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';
|
||||
@@ -16,7 +17,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeController = context.watch<ThemeController>();
|
||||
final items = context.watch<ItemController>();
|
||||
final bank = context.watch<BankController>();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
@@ -56,19 +57,58 @@ class SettingsScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Text('Data', style: theme.textTheme.titleMedium),
|
||||
Text('Backend', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Use remote sample API'),
|
||||
subtitle: const Text(
|
||||
'When on, items are fetched with the http package from '
|
||||
'JSONPlaceholder. Failures fall back to local mock data.',
|
||||
),
|
||||
value: items.useRemoteData,
|
||||
onChanged: (value) => items.setUseRemoteData(value),
|
||||
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: 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),
|
||||
Text('About', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
@@ -78,7 +118,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
const ListTile(
|
||||
leading: Icon(Icons.apps),
|
||||
title: Text(kAppName),
|
||||
subtitle: Text('Generic Flutter Android starter'),
|
||||
subtitle: Text('Personal bank data via Enable Banking'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const ListTile(
|
||||
|
||||
@@ -90,7 +90,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.layers_rounded,
|
||||
Icons.account_balance_rounded,
|
||||
size: 44,
|
||||
color: colors.onPrimary,
|
||||
),
|
||||
@@ -103,12 +103,12 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'A Material 3 starter template',
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colors.onPrimary.withValues(alpha: 0.85),
|
||||
Text(
|
||||
'Your bank data, simply',
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colors.onPrimary.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
library;
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
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.
|
||||
abstract final class StorageKeys {
|
||||
static const themeMode = 'theme_mode';
|
||||
|
||||
@@ -57,3 +57,31 @@ String labelForThemeMode(ThemeMode mode) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -96,6 +128,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gtk
|
||||
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -373,6 +413,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -37,6 +37,8 @@ dependencies:
|
||||
provider: ^6.1.5+1 # ChangeNotifier state for theme and catalog
|
||||
http: ^1.6.0 # Placeholder network client (JSONPlaceholder)
|
||||
shared_preferences: ^2.5.5 # Persist theme mode and data-source toggle
|
||||
app_links: ^7.2.1
|
||||
webview_flutter: ^4.14.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
+23
-35
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:myapp/main.dart';
|
||||
import 'package:myapp/models/item.dart';
|
||||
import 'package:myapp/services/item_repository.dart';
|
||||
import 'package:myapp/models/bank.dart';
|
||||
import 'package:myapp/services/storage_service.dart';
|
||||
import 'package:myapp/utils/constants.dart';
|
||||
import 'package:myapp/utils/helpers.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
@@ -13,15 +13,10 @@ void main() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final storage = StorageService();
|
||||
await storage.init();
|
||||
await tester.pumpWidget(
|
||||
MyApp(
|
||||
storage: storage,
|
||||
repository: ItemRepository(),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(MyApp(storage: storage));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
expect(find.text(kAppName), findsWidgets);
|
||||
@@ -30,37 +25,30 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 450));
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
expect(find.text('Home'), findsWidgets);
|
||||
expect(find.text('Featured items'), findsOneWidget);
|
||||
expect(find.text('Search'), findsOneWidget);
|
||||
expect(find.text('Profile'), findsOneWidget);
|
||||
expect(find.text('Balance'), findsWidgets);
|
||||
expect(find.text('Transactions'), findsOneWidget);
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping a list card opens the detail screen', (tester) async {
|
||||
await pumpApp(tester);
|
||||
await tester.pump(kSplashDuration);
|
||||
await tester.pump(const Duration(milliseconds: 450));
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
await tester.tap(find.text('Material 3 surfaces'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.text('Details'), findsOneWidget);
|
||||
expect(find.text('Back to list'), findsOneWidget);
|
||||
test('ConnectStart.fromJson maps authUrl and state', () {
|
||||
final start = ConnectStart.fromJson({
|
||||
'authUrl': 'https://example.test/auth',
|
||||
'state': 'abc',
|
||||
});
|
||||
expect(start.authUrl, 'https://example.test/auth');
|
||||
expect(start.state, 'abc');
|
||||
});
|
||||
|
||||
test('Item.fromJson maps a JSONPlaceholder post', () {
|
||||
final item = Item.fromJson({
|
||||
'id': 3,
|
||||
'title': 'hello world',
|
||||
'body': 'First sentence. Second sentence.',
|
||||
test('BankTransaction.fromJson maps API fields', () {
|
||||
final tx = BankTransaction.fromJson({
|
||||
'date': '2026-09-01',
|
||||
'description': 'Coffee',
|
||||
'amount': -3.5,
|
||||
'currency': 'EUR',
|
||||
'pending': false,
|
||||
});
|
||||
|
||||
expect(item.id, 'remote-3');
|
||||
expect(item.title, 'Hello world');
|
||||
expect(item.subtitle, 'First sentence.');
|
||||
expect(item.category, ItemCategory.lifestyle);
|
||||
expect(tx.description, 'Coffee');
|
||||
expect(tx.amount, -3.5);
|
||||
expect(formatMoney(tx.amount, tx.currency), contains('EUR'));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user