sign in with pocketbase working

This commit is contained in:
davmar
2026-09-09 19:59:38 +02:00
parent ae464ff304
commit c6d1fdb19e
27 changed files with 1267 additions and 312 deletions
+10 -2
View File
@@ -7,30 +7,37 @@ import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/auth_provider.dart';
import 'providers/bank_provider.dart';
import 'providers/theme_provider.dart';
import 'providers/tx_meta_provider.dart';
import 'screens/splash_screen.dart';
import 'services/storage_service.dart';
import 'utils/app_config.dart';
import 'utils/app_theme.dart';
import 'utils/constants.dart';
import 'widgets/skip_build_when_covered.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await AppConfig.load();
final storage = StorageService();
await storage.init();
runApp(MyApp(storage: storage));
final auth = AuthController(storage);
await auth.init();
runApp(MyApp(storage: storage, auth: auth));
}
/// 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});
const MyApp({super.key, required this.storage, required this.auth});
final StorageService storage;
final AuthController auth;
@override
State<MyApp> createState() => _MyAppState();
@@ -80,6 +87,7 @@ class _MyAppState extends State<MyApp> {
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: widget.auth),
ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)),
ChangeNotifierProvider(create: (_) => TxMetaController(widget.storage)),
ChangeNotifierProvider<BankController>.value(value: _bank),
+17 -2
View File
@@ -217,12 +217,16 @@ class TxMeta {
const TxMeta({
this.labelIds = const [],
this.recurring = false,
this.internalTransfer = false,
this.notes = '',
});
/// Zero or more label ids on this transaction.
final List<String> labelIds;
final bool recurring;
/// Move between own accounts — excluded from income/expense cashflow.
final bool internalTransfer;
final String notes;
static const empty = TxMeta();
@@ -239,11 +243,13 @@ class TxMeta {
List<String>? labelIds,
bool clearLabels = false,
bool? recurring,
bool? internalTransfer,
String? notes,
}) {
return TxMeta(
labelIds: clearLabels ? const [] : (labelIds ?? this.labelIds),
recurring: recurring ?? this.recurring,
internalTransfer: internalTransfer ?? this.internalTransfer,
notes: notes ?? this.notes,
);
}
@@ -254,6 +260,7 @@ class TxMeta {
'labelId': primaryLabelId,
'category': primaryLabelId,
'recurring': recurring,
'internalTransfer': internalTransfer,
'notes': notes,
};
@@ -261,6 +268,7 @@ class TxMeta {
return TxMeta(
labelIds: _parseLabelIds(json),
recurring: json['recurring'] as bool? ?? false,
internalTransfer: json['internalTransfer'] as bool? ?? false,
notes: json['notes'] as String? ?? '',
);
}
@@ -290,22 +298,29 @@ String? _migrateLegacyCategory(String? name) {
return name;
}
/// Notes / recurring template keyed by exact merchant+amount match.
/// Notes / recurring / transfer template keyed by exact merchant+amount match.
class TxExactTemplate {
const TxExactTemplate({this.notes = '', this.recurring = false});
const TxExactTemplate({
this.notes = '',
this.recurring = false,
this.internalTransfer = false,
});
final String notes;
final bool recurring;
final bool internalTransfer;
Map<String, dynamic> toJson() => {
'notes': notes,
'recurring': recurring,
'internalTransfer': internalTransfer,
};
factory TxExactTemplate.fromJson(Map<String, dynamic> json) {
return TxExactTemplate(
notes: json['notes'] as String? ?? '',
recurring: json['recurring'] as bool? ?? false,
internalTransfer: json['internalTransfer'] as bool? ?? false,
);
}
}
+87
View File
@@ -0,0 +1,87 @@
/// PocketBase sign-in state. Local tx/bank data is unchanged by auth.
library;
import 'package:flutter/foundation.dart';
import 'package:pocketbase/pocketbase.dart';
import '../services/storage_service.dart';
import '../utils/constants.dart';
/// Owns the PocketBase client, session persistence, and login/logout.
class AuthController extends ChangeNotifier {
AuthController(this._storage);
final StorageService _storage;
PocketBase? _client;
PocketBase get client {
final pb = _client;
if (pb == null) {
throw StateError('AuthController.init() must be called before use.');
}
return pb;
}
bool get isLoggedIn => _client?.authStore.isValid ?? false;
RecordModel? get user => _client?.authStore.record;
String? get email {
final value = user?.getStringValue('email');
return value == null || value.isEmpty ? null : value;
}
String? get name {
final value = user?.getStringValue('name');
return value == null || value.isEmpty ? null : value;
}
String get displayName {
final n = name;
if (n != null && n.isNotEmpty) return n;
final e = email;
if (e != null && e.isNotEmpty) return e.split('@').first;
return 'Account';
}
String get initials {
final source = name ?? email ?? '?';
final parts = source.trim().split(RegExp(r'\s+'));
if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
if (source.isNotEmpty) return source[0].toUpperCase();
return '?';
}
Future<void> init() async {
final store = AsyncAuthStore(
save: (String data) async {
if (data.isEmpty) {
await _storage.clearPocketBaseAuth();
} else {
await _storage.setPocketBaseAuth(data);
}
},
initial: _storage.pocketBaseAuth,
);
_client = PocketBase(kPocketBaseUrl, authStore: store);
_client!.authStore.onChange.listen((_) => notifyListeners());
notifyListeners();
}
Future<void> login({
required String email,
required String password,
}) async {
await client
.collection(kPocketBaseUsersCollection)
.authWithPassword(email.trim(), password);
notifyListeners();
}
Future<void> logout() async {
client.authStore.clear();
notifyListeners();
}
}
+44 -3
View File
@@ -278,11 +278,14 @@ class TxMetaController extends ChangeNotifier {
: (inheritedNotes ?? ''));
final recurring = stored?.recurring ?? template?.recurring ?? false;
final internalTransfer =
stored?.internalTransfer ?? template?.internalTransfer ?? false;
return TxMeta(
labelIds: List<String>.of(labelIds),
notes: notes,
recurring: recurring,
internalTransfer: internalTransfer,
);
}
@@ -570,6 +573,29 @@ class TxMetaController extends ChangeNotifier {
tx.exactKey: TxExactTemplate(
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
recurring: recurring,
internalTransfer:
existing.internalTransfer || current.internalTransfer,
),
};
_schedulePersist();
}
Future<void> setInternalTransfer(
BankTransaction tx,
bool internalTransfer,
) async {
final current = metaFor(tx);
_byId = {
..._byId,
tx.id: current.copyWith(internalTransfer: internalTransfer),
};
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate();
_exactTemplates = {
..._exactTemplates,
tx.exactKey: TxExactTemplate(
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
recurring: existing.recurring || current.recurring,
internalTransfer: internalTransfer,
),
};
_schedulePersist();
@@ -604,7 +630,12 @@ class TxMetaController extends ChangeNotifier {
if (!applying) {
final current = metaFor(tx);
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate();
if (trimmed.isEmpty && !existing.recurring && !current.recurring) {
final keepTemplate = trimmed.isNotEmpty ||
existing.recurring ||
current.recurring ||
existing.internalTransfer ||
current.internalTransfer;
if (!keepTemplate) {
_exactTemplates = Map.of(_exactTemplates)..remove(tx.exactKey);
} else {
_exactTemplates = {
@@ -612,6 +643,8 @@ class TxMetaController extends ChangeNotifier {
tx.exactKey: TxExactTemplate(
notes: trimmed,
recurring: existing.recurring || current.recurring,
internalTransfer:
existing.internalTransfer || current.internalTransfer,
),
};
}
@@ -641,6 +674,10 @@ class TxMetaController extends ChangeNotifier {
return setRecurring(tx, !metaFor(tx).recurring);
}
Future<void> toggleInternalTransfer(BankTransaction tx) {
return setInternalTransfer(tx, !metaFor(tx).internalTransfer);
}
/// Search description, notes, label name, and date.
List<BankTransaction> search(
List<BankTransaction> txs,
@@ -751,7 +788,9 @@ class TxMetaController extends ChangeNotifier {
final totals = <String, double>{};
for (final tx in txs) {
if (tx.amount >= 0) continue;
final ids = metaFor(tx).labelIds;
final meta = metaFor(tx);
if (meta.internalTransfer) continue;
final ids = meta.labelIds;
if (ids.isEmpty) continue;
final amount = tx.amount.abs();
for (final id in ids) {
@@ -766,7 +805,8 @@ class TxMetaController extends ChangeNotifier {
var total = 0.0;
for (final tx in txs) {
if (tx.amount >= 0) continue;
if (!metaFor(tx).hasLabel) continue;
final meta = metaFor(tx);
if (meta.internalTransfer || !meta.hasLabel) continue;
total += tx.amount.abs();
}
return total;
@@ -787,6 +827,7 @@ class TxMetaController extends ChangeNotifier {
}
for (final tx in txs) {
if (tx.amount >= 0) continue;
if (metaFor(tx).internalTransfer) continue;
final parsed = DateTime.tryParse(tx.date);
if (parsed == null) continue;
if (parsed.year != month.year || parsed.month != month.month) continue;
+90
View File
@@ -0,0 +1,90 @@
/// Signed-in account details and sign-out.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../utils/constants.dart';
import '../widgets/responsive_body.dart';
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthController>();
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Account')),
body: ResponsiveBody(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 32),
children: [
Center(
child: CircleAvatar(
radius: 36,
backgroundColor: colors.primaryContainer,
child: Text(
auth.initials,
style: theme.textTheme.headlineSmall?.copyWith(
color: colors.onPrimaryContainer,
),
),
),
),
const SizedBox(height: 16),
Text(
auth.displayName,
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall,
),
if (auth.email case final email?)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
email,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
const SizedBox(height: 24),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('PocketBase server'),
subtitle: Text(kPocketBaseUrl),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.phone_android_outlined),
title: const Text('Local data'),
subtitle: const Text(
'Labels, notes, and bank cache remain on this device.',
),
),
],
),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () async {
await auth.logout();
if (!context.mounted) return;
Navigator.of(context).pop();
},
icon: const Icon(Icons.logout),
label: const Text('Sign out'),
),
],
),
),
);
}
}
+156
View File
@@ -0,0 +1,156 @@
/// Email/password sign-in against PocketBase.
library;
import 'package:flutter/material.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../utils/constants.dart';
import '../widgets/app_text_field.dart';
import '../widgets/responsive_body.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _busy = false;
bool _obscurePassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_busy) return;
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _busy = true);
final auth = context.read<AuthController>();
try {
await auth.login(
email: _emailController.text,
password: _passwordController.text,
);
if (!mounted) return;
Navigator.of(context).pop(true);
} on ClientException catch (error) {
if (!mounted) return;
final message = error.response['message'] as String? ??
'Sign-in failed. Check email and password.';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not reach $kPocketBaseUrl')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Sign in')),
body: ResponsiveBody(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 32),
children: [
Text(
'Sync account',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Sign in to link this device with PocketBase. Your labels, '
'notes, and bank cache stay on this phone until sync is added.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
Form(
key: _formKey,
child: Column(
children: [
AppTextField(
controller: _emailController,
label: 'Email',
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofocus: true,
validator: (value) {
final text = value?.trim() ?? '';
if (text.isEmpty) return 'Enter your email';
if (!text.contains('@')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter your password';
}
return null;
},
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
tooltip: _obscurePassword
? 'Show password'
: 'Hide password',
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _busy ? null : _submit,
icon: _busy
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.login),
label: Text(_busy ? 'Signing in…' : 'Sign in'),
),
],
),
),
],
),
),
);
}
}
+4 -1
View File
@@ -212,7 +212,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {};
if (_metric == _MetricMode.flow) {
monthly = _flowTotals(txs, periods, _period);
monthly = _flowTotals(txs, meta, periods, _period);
} else {
final labels = _labelAggregates(
txs,
@@ -478,6 +478,7 @@ String _periodAxisLabel(DateTime period, _PeriodMode mode) {
Map<DateTime, _MonthBucket> _flowTotals(
List<BankTransaction> txs,
TxMetaController meta,
List<DateTime> periods,
_PeriodMode mode,
) {
@@ -486,6 +487,7 @@ Map<DateTime, _MonthBucket> _flowTotals(
for (final key in keys) key: (income: 0.0, expense: 0.0),
};
for (final tx in txs) {
if (meta.metaFor(tx).internalTransfer) continue;
final date = _txDate(tx);
if (date == null) continue;
final key = _periodKey(date, mode);
@@ -545,6 +547,7 @@ _LabelAggregates _labelAggregates(
final filtering = includedLabelIds.isNotEmpty;
for (final tx in txs) {
if (meta.metaFor(tx).internalTransfer) continue;
if (!txMatchesFlow(tx, flow)) continue;
final date = _txDate(tx);
if (date == null) continue;
@@ -397,6 +397,22 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
context.read<TxMetaController>().setRecurring(tx, value);
},
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.swap_horiz),
title: const Text('Internal transfer'),
subtitle: const Text(
'Between your own accounts — excluded from income/expense',
),
value: meta.internalTransfer,
onChanged: _busy
? null
: (value) {
context
.read<TxMetaController>()
.setInternalTransfer(tx, value);
},
),
const SizedBox(height: 16),
Row(
children: [
+2
View File
@@ -354,6 +354,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
var moneyOut = 0.0;
var moneyIn = 0.0;
for (final tx in filtered) {
if (metaController.metaFor(tx).internalTransfer) continue;
if (tx.amount < 0) {
moneyOut += tx.amount.abs();
} else if (tx.amount > 0) {
@@ -952,6 +953,7 @@ class _CompactTransactionTile extends StatelessWidget {
if (labels.isNotEmpty)
labels.map((l) => l.name).join(', '),
if (meta.recurring) 'Recurring',
if (meta.internalTransfer) 'Transfer',
if (meta.hasNotes) 'Note',
if (tx.pending) 'Pending',
].join(' · '),
+3 -2
View File
@@ -10,8 +10,9 @@ 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();
IngtrakApi({http.Client? client, String? baseUrl})
: _client = client ?? http.Client(),
baseUrl = baseUrl ?? kApiBaseUrl;
final http.Client _client;
final String baseUrl;
+10
View File
@@ -125,6 +125,16 @@ class StorageService {
return _prefs.setBool(StorageKeys.useRemoteData, value);
}
String? get pocketBaseAuth => _prefs.getString(StorageKeys.pocketBaseAuth);
Future<void> setPocketBaseAuth(String value) {
return _prefs.setString(StorageKeys.pocketBaseAuth, value);
}
Future<void> clearPocketBaseAuth() {
return _prefs.remove(StorageKeys.pocketBaseAuth);
}
BankCache? loadBankCache() {
final raw = _prefs.getString(StorageKeys.bankCache);
if (raw == null || raw.isEmpty) return null;
+66
View File
@@ -0,0 +1,66 @@
/// Runtime config from `.env`, with optional `--dart-define` overrides.
library;
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// Loads once at startup from the `.env` asset (see `.env.example`).
/// Pass `--dart-define=KEY=value` to override for CI / one-off builds.
abstract final class AppConfig {
static var _loaded = false;
static late String apiBaseUrl;
static late String pocketBaseUrl;
static late String pocketBaseUsersCollection;
static Future<void> load() async {
if (_loaded) return;
try {
await dotenv.load(fileName: '.env', isOptional: true);
if (dotenv.env.isEmpty) {
await dotenv.load(fileName: '.env.example', isOptional: true);
}
} catch (_) {
// Missing asset in tests is fine — use fallbacks / loadForTest.
}
apiBaseUrl = _resolve(
dartDefine: const String.fromEnvironment('API_BASE_URL'),
envKey: 'API_BASE_URL',
fallback: 'https://ingtrak.satoshinakamoto.win',
);
pocketBaseUrl = _resolve(
dartDefine: const String.fromEnvironment('POCKETBASE_URL'),
envKey: 'POCKETBASE_URL',
fallback: 'http://127.0.0.1:8090',
);
pocketBaseUsersCollection = _resolve(
dartDefine: const String.fromEnvironment('POCKETBASE_USERS_COLLECTION'),
envKey: 'POCKETBASE_USERS_COLLECTION',
fallback: 'usersapp',
);
_loaded = true;
}
/// For widget tests that don't go through [main].
static void loadForTest({
String apiBaseUrl = 'https://ingtrak.satoshinakamoto.win',
String pocketBaseUrl = 'http://127.0.0.1:8090',
String pocketBaseUsersCollection = 'usersapp',
}) {
AppConfig.apiBaseUrl = apiBaseUrl;
AppConfig.pocketBaseUrl = pocketBaseUrl;
AppConfig.pocketBaseUsersCollection = pocketBaseUsersCollection;
_loaded = true;
}
static String _resolve({
required String dartDefine,
required String envKey,
required String fallback,
}) {
if (dartDefine.isNotEmpty) return dartDefine;
final fromEnv = dotenv.maybeGet(envKey)?.trim();
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
return fallback;
}
}
-3
View File
@@ -240,9 +240,6 @@ abstract final class AppTheme {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
+14 -12
View File
@@ -1,6 +1,8 @@
/// Shared string keys, timings, and layout breakpoints used across the app.
library;
import 'app_config.dart';
/// Human-readable name shown in the splash screen, app bars, and About tile.
const String kAppName = 'IngTrak';
@@ -10,18 +12,14 @@ 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=https://ingtrak.satoshinakamoto.win`
///
/// Local defaults (when override omitted):
/// - Android emulator → `http://10.0.2.2:8000`
/// - Physical device on LAN → `http://192.168.x.x:8000`
const String kApiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://ingtrak.satoshinakamoto.win',
);
/// FastAPI bank backend. From `.env` / `--dart-define=API_BASE_URL=…`.
String get kApiBaseUrl => AppConfig.apiBaseUrl;
/// PocketBase server. From `.env` / `--dart-define=POCKETBASE_URL=…`.
String get kPocketBaseUrl => AppConfig.pocketBaseUrl;
/// PocketBase auth collection name (default `usersapp`).
String get kPocketBaseUsersCollection => AppConfig.pocketBaseUsersCollection;
/// Enable Banking redirect derived from [kApiBaseUrl].
/// Must match backend `REDIRECT_URI` and the Control Panel whitelist.
@@ -32,6 +30,7 @@ String get kEnableBankingRedirectUri {
: kApiBaseUrl;
return '$base/api/connect/callback';
}
/// Optional app-scheme bounce used after the http callback (not registered
/// with Enable Banking).
const String kBankRedirectScheme = 'ingtrak';
@@ -46,6 +45,9 @@ abstract final class StorageKeys {
static const txMetaLegacy = 'tx_meta_v1';
static const bankCache = 'bank_cache_v1';
static const importedTransactions = 'imported_txs_v1';
/// Serialized PocketBase auth token + user record (separate from tx data).
static const pocketBaseAuth = 'pb_auth_v1';
}
/// Named routes registered on [MaterialApp].
+80
View File
@@ -2,9 +2,88 @@
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../screens/account_screen.dart';
import '../screens/login_screen.dart';
import '../screens/settings_screen.dart';
Widget authAppBarAction(BuildContext context) {
final auth = context.watch<AuthController>();
if (auth.isLoggedIn) {
return PopupMenuButton<_AccountMenuAction>(
tooltip: auth.displayName,
offset: const Offset(0, 48),
onSelected: (action) async {
switch (action) {
case _AccountMenuAction.account:
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const AccountScreen()),
);
case _AccountMenuAction.logout:
await auth.logout();
}
},
itemBuilder: (context) {
final email = auth.email;
return [
if (email != null)
PopupMenuItem<_AccountMenuAction>(
enabled: false,
child: Text(
email,
style: Theme.of(context).textTheme.bodySmall,
),
),
const PopupMenuItem<_AccountMenuAction>(
value: _AccountMenuAction.account,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.person_outline),
title: Text('Account'),
),
),
const PopupMenuItem<_AccountMenuAction>(
value: _AccountMenuAction.logout,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.logout),
title: Text('Sign out'),
),
),
];
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Text(
auth.initials,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),
);
}
return IconButton(
tooltip: 'Sign in',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<bool>(builder: (_) => const LoginScreen()),
);
},
icon: const Icon(Icons.person_outline),
);
}
enum _AccountMenuAction { account, logout }
List<Widget> bankAppBarActions({
required BuildContext context,
VoidCallback? onRefresh,
@@ -17,6 +96,7 @@ List<Widget> bankAppBarActions({
onPressed: refreshing ? null : onRefresh,
icon: const Icon(Icons.refresh),
),
authAppBarAction(context),
IconButton(
tooltip: 'Settings',
onPressed: () {
+257 -277
View File
@@ -91,15 +91,20 @@ Future<TransactionFilterSelection?> showTransactionFilterDialog(
useSafeArea: true,
showDragHandle: true,
builder: (dialogContext) {
final media = MediaQuery.of(dialogContext);
// Keep the sheet above the keyboard without overflowing.
final maxHeight =
(media.size.height - media.viewInsets.bottom) * 0.92;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(dialogContext).bottom,
),
child: _TransactionFilterDialog(
available: available,
transactions: transactions,
initial: initial,
currentMonth: currentMonth ?? monthStart(DateTime.now()),
padding: EdgeInsets.only(bottom: media.viewInsets.bottom),
child: SizedBox(
height: maxHeight.clamp(320.0, media.size.height),
child: _TransactionFilterDialog(
available: available,
transactions: transactions,
initial: initial,
currentMonth: currentMonth ?? monthStart(DateTime.now()),
),
),
);
},
@@ -201,14 +206,17 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
final showUncategorized = widget.includeUncategorized &&
(needle.isEmpty ||
kUncategorizedFilterLabel.name.toLowerCase().contains(needle));
final maxListHeight = MediaQuery.sizeOf(context).height * 0.45;
final media = MediaQuery.of(context);
final maxContentHeight =
((media.size.height - media.viewInsets.bottom) * 0.55)
.clamp(240.0, 560.0);
return AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: double.maxFinite,
height: maxContentHeight,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
@@ -278,8 +286,7 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
),
],
const SizedBox(height: 8),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxListHeight),
Expanded(
child: _LabelChecklist(
entries: entries,
showUncategorized: showUncategorized,
@@ -483,7 +490,6 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final meta = context.watch<TxMetaController>();
final size = MediaQuery.sizeOf(context);
final needle = _labelSearchController.text.trim().toLowerCase();
final entries = _filterLabelEntries(
meta: meta,
@@ -494,285 +500,253 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
kUncategorizedFilterLabel.name.toLowerCase().contains(needle);
final sectionStyle = _sectionStyle(theme, colors);
// Fixed sheet height so Expanded labels get a real viewport (not a tiny 220px box).
final sheetHeight = (size.height * 0.92).clamp(420.0, size.height);
return SizedBox(
height: sheetHeight,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filter transactions',
style: theme.textTheme.titleLarge,
// Parent SizedBox already bounds height (and accounts for keyboard).
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filter transactions',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'Search, period, flow, amount, and labels.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
const SizedBox(height: 4),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 8),
children: [
Text('Search text', style: sectionStyle),
const SizedBox(height: 8),
TextField(
controller: _queryController,
textInputAction: TextInputAction.search,
decoration: const InputDecoration(
hintText: 'Description, notes, amount…',
prefixIcon: Icon(Icons.search),
),
),
const SizedBox(height: 14),
Text('Period', style: sectionStyle),
const SizedBox(height: 8),
_RectToggleBar<bool>(
value: _monthOnly,
onChanged: (value) => setState(() => _monthOnly = value),
options: const [
_RectToggleOption(
value: false,
label: 'All',
),
_RectToggleOption(
value: true,
label: 'Month',
icon: Icons.calendar_month,
),
],
),
if (_monthOnly) ...[
const SizedBox(height: 8),
Text(
'Search, period, flow, amount, and labels.',
style: theme.textTheme.bodyMedium?.copyWith(
'Uses ${formatMonthYear(widget.currentMonth)}. '
'Change month on the transactions list.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
flex: 2,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Search text', style: sectionStyle),
const SizedBox(height: 8),
TextField(
controller: _queryController,
textInputAction: TextInputAction.search,
decoration: const InputDecoration(
hintText: 'Description, notes, amount…',
prefixIcon: Icon(Icons.search),
),
),
const SizedBox(height: 14),
Text('Period', style: sectionStyle),
const SizedBox(height: 8),
_RectToggleBar<bool>(
value: _monthOnly,
onChanged: (value) =>
setState(() => _monthOnly = value),
options: const [
_RectToggleOption(
value: false,
label: 'All',
),
_RectToggleOption(
value: true,
label: 'Month',
icon: Icons.calendar_month,
),
],
),
if (_monthOnly) ...[
const SizedBox(height: 8),
Text(
'Uses ${formatMonthYear(widget.currentMonth)}. '
'Change month on the transactions list.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
const SizedBox(height: 14),
Text('Income / expense', style: sectionStyle),
const SizedBox(height: 8),
_RectToggleBar<TxFlowFilter>(
value: _flow,
onChanged: (value) => setState(() => _flow = value),
options: const [
_RectToggleOption(
value: TxFlowFilter.all,
label: 'All',
),
_RectToggleOption(
value: TxFlowFilter.income,
label: 'In',
icon: Icons.south_west,
),
_RectToggleOption(
value: TxFlowFilter.expense,
label: 'Out',
icon: Icons.north_east,
),
],
),
const SizedBox(height: 14),
Text('Amount', style: sectionStyle),
const SizedBox(height: 8),
Text(
'Exact',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
TextField(
controller: _exactAmountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
isDense: true,
hintText: 'e.g. 12.50',
prefixIcon: const Icon(Icons.pin, size: 20),
suffixIcon:
_exactAmountController.text.trim().isEmpty
? null
: IconButton(
tooltip: 'Clear exact',
onPressed: () {
_exactAmountController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) {
setState(() {
_rangeTouched = false;
_amountRange = RangeValues(0, _amountCeil);
});
const SizedBox(height: 14),
Text('Income / expense', style: sectionStyle),
const SizedBox(height: 8),
_RectToggleBar<TxFlowFilter>(
value: _flow,
onChanged: (value) => setState(() => _flow = value),
options: const [
_RectToggleOption(
value: TxFlowFilter.all,
label: 'All',
),
_RectToggleOption(
value: TxFlowFilter.income,
label: 'In',
icon: Icons.south_west,
),
_RectToggleOption(
value: TxFlowFilter.expense,
label: 'Out',
icon: Icons.north_east,
),
],
),
const SizedBox(height: 14),
Text('Amount', style: sectionStyle),
const SizedBox(height: 8),
Text(
'Exact',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
TextField(
controller: _exactAmountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
isDense: true,
hintText: 'e.g. 12.50',
prefixIcon: const Icon(Icons.pin, size: 20),
suffixIcon: _exactAmountController.text.trim().isEmpty
? null
: IconButton(
tooltip: 'Clear exact',
onPressed: () {
_exactAmountController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
const SizedBox(height: 12),
Text(
'Range',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
_rangeTouched
? '${_amountRange.start.toStringAsFixed(0)} – ${_amountRange.end.toStringAsFixed(0)}'
: 'Any amount',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
RangeSlider(
values: _amountRange,
min: 0,
max: _amountCeil,
labels: RangeLabels(
_amountRange.start.toStringAsFixed(0),
_amountRange.end.toStringAsFixed(0),
),
onChanged: (values) {
setState(() {
_exactAmountController.clear();
_rangeTouched = true;
_amountRange = values;
});
),
onChanged: (_) {
setState(() {
_rangeTouched = false;
_amountRange = RangeValues(0, _amountCeil);
});
},
),
const SizedBox(height: 12),
Text(
'Range',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
_rangeTouched
? '${_amountRange.start.toStringAsFixed(0)} – ${_amountRange.end.toStringAsFixed(0)}'
: 'Any amount',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
RangeSlider(
values: _amountRange,
min: 0,
max: _amountCeil,
labels: RangeLabels(
_amountRange.start.toStringAsFixed(0),
_amountRange.end.toStringAsFixed(0),
),
onChanged: (values) {
setState(() {
_exactAmountController.clear();
_rangeTouched = true;
_amountRange = values;
});
},
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: !_rangeTouched
? null
: () {
setState(() {
_rangeTouched = false;
_amountRange = RangeValues(0, _amountCeil);
});
},
child: const Text('Reset range'),
),
),
const SizedBox(height: 8),
Text('Labels', style: sectionStyle),
const SizedBox(height: 8),
TextField(
controller: _labelSearchController,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: !_rangeTouched
? null
: () {
setState(() {
_rangeTouched = false;
_amountRange =
RangeValues(0, _amountCeil);
});
},
child: const Text('Reset range'),
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Labels', style: sectionStyle),
const SizedBox(height: 8),
TextField(
controller: _labelSearchController,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) => setState(() {}),
),
Row(
children: [
TextButton(
onPressed: () {
setState(() {
_selected
..clear()
..addAll(entries.map((e) => e.label.id));
if (showUncategorized) {
_selected.add(kUncategorizedFilterId);
}
});
},
child: const Text('All labels'),
),
TextButton(
onPressed: () =>
setState(() => _selected.clear()),
child: const Text('No labels'),
),
],
),
],
onChanged: (_) => setState(() {}),
),
Row(
children: [
TextButton(
onPressed: () {
setState(() {
_selected
..clear()
..addAll(entries.map((e) => e.label.id));
if (showUncategorized) {
_selected.add(kUncategorizedFilterId);
}
});
},
child: const Text('All labels'),
),
),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: _LabelChecklist(
entries: entries,
showUncategorized: showUncategorized,
selected: _selected,
onToggle: _toggle,
allowMove: false,
),
TextButton(
onPressed: () => setState(() => _selected.clear()),
child: const Text('No labels'),
),
),
],
),
],
),
const SizedBox(height: 4),
// Inline (no nested scroll) so the whole sheet scrolls as one.
_LabelChecklist(
entries: entries,
showUncategorized: showUncategorized,
selected: _selected,
onToggle: _toggle,
allowMove: false,
shrinkWrap: true,
),
const SizedBox(height: 12),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(
onPressed: _apply,
child: const Text('Apply'),
),
const SizedBox(height: 4),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(
onPressed: _apply,
child: const Text('Apply'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
],
),
),
],
);
}
}
@@ -804,6 +778,7 @@ class _LabelChecklist extends StatelessWidget {
required this.selected,
required this.onToggle,
required this.allowMove,
this.shrinkWrap = false,
});
final List<LabelTreeEntry> entries;
@@ -811,6 +786,7 @@ class _LabelChecklist extends StatelessWidget {
final Set<String> selected;
final void Function(String id, bool selected) onToggle;
final bool allowMove;
final bool shrinkWrap;
@override
Widget build(BuildContext context) {
@@ -850,6 +826,10 @@ class _LabelChecklist extends StatelessWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: ListView.separated(
shrinkWrap: shrinkWrap,
physics: shrinkWrap
? const NeverScrollableScrollPhysics()
: null,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: entries.length + (showUncategorized ? 1 : 0),
separatorBuilder: (_, _) => Divider(
+6 -5
View File
@@ -147,14 +147,17 @@ class _SimilarMatchDialogState extends State<_SimilarMatchDialog> {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final matches = _matches;
final maxListHeight = MediaQuery.sizeOf(context).height * 0.35;
final media = MediaQuery.of(context);
final maxContentHeight =
((media.size.height - media.viewInsets.bottom) * 0.55)
.clamp(240.0, 560.0);
return AlertDialog(
title: const Text('Match similar'),
content: SizedBox(
width: double.maxFinite,
height: maxContentHeight,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
@@ -212,8 +215,7 @@ class _SimilarMatchDialogState extends State<_SimilarMatchDialog> {
),
),
const SizedBox(height: 6),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxListHeight),
Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
@@ -233,7 +235,6 @@ class _SimilarMatchDialogState extends State<_SimilarMatchDialog> {
),
)
: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: matches.length,
separatorBuilder: (_, _) => Divider(