sign in with pocketbase working
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# App endpoints (no secrets). Copy from `.env.example` if missing.
|
||||
# Optional overrides: flutter run --dart-define=POCKETBASE_URL=https://other.host
|
||||
|
||||
API_BASE_URL=https://ingtrak.satoshinakamoto.win
|
||||
POCKETBASE_URL=https://pb.satoshinakamoto.win
|
||||
POCKETBASE_USERS_COLLECTION=usersapp
|
||||
@@ -0,0 +1,6 @@
|
||||
# App endpoints (no secrets). Copy to `.env` if needed.
|
||||
# Optional overrides: flutter run --dart-define=POCKETBASE_URL=https://other.host
|
||||
|
||||
API_BASE_URL=https://ingtrak.satoshinakamoto.win
|
||||
POCKETBASE_URL=https://pb.satoshinakamoto.win
|
||||
POCKETBASE_USERS_COLLECTION=usersapp
|
||||
@@ -1,3 +1,7 @@
|
||||
# Local overrides — copy from `.env.example`. Public URLs are fine to commit;
|
||||
# never put passwords or API secrets here.
|
||||
# .env
|
||||
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
|
||||
+10
-2
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(' · '),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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].
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -142,6 +142,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_dotenv:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_dotenv
|
||||
sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.1"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -192,6 +200,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -304,6 +320,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pocketbase:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pocketbase
|
||||
sha256: "4fef3019f45d6d7c4526ee7cc0e67a72f534fcb4f80da3e3d437daf96814b61b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.25.1"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+5
-4
@@ -41,6 +41,8 @@ dependencies:
|
||||
webview_flutter: ^4.14.1
|
||||
fl_chart: ^1.2.0
|
||||
file_picker: ^8.1.7
|
||||
pocketbase: ^0.25.1
|
||||
flutter_dotenv: ^6.0.1
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -63,10 +65,9 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- .env
|
||||
- .env.example
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:myapp/models/bank.dart';
|
||||
import 'package:myapp/providers/auth_provider.dart';
|
||||
import 'package:myapp/providers/bank_provider.dart';
|
||||
import 'package:myapp/providers/theme_provider.dart';
|
||||
import 'package:myapp/providers/tx_meta_provider.dart';
|
||||
import 'package:myapp/screens/main_shell.dart';
|
||||
import 'package:myapp/screens/transaction_detail_screen.dart';
|
||||
import 'package:myapp/services/storage_service.dart';
|
||||
import 'package:myapp/utils/app_config.dart';
|
||||
import 'package:myapp/utils/app_theme.dart';
|
||||
import 'package:myapp/widgets/label_filter_dialog.dart';
|
||||
|
||||
BankTransaction tx(String desc, double amount) => BankTransaction(
|
||||
date: '2026-09-01',
|
||||
description: desc,
|
||||
amount: amount,
|
||||
currency: 'EUR',
|
||||
pending: false,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late StorageService storage;
|
||||
late AuthController auth;
|
||||
late BankController bank;
|
||||
late TxMetaController meta;
|
||||
|
||||
setUp(() async {
|
||||
AppConfig.loadForTest();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
storage = StorageService();
|
||||
await storage.init();
|
||||
auth = AuthController(storage);
|
||||
await auth.init();
|
||||
bank = BankController(storage: storage);
|
||||
meta = TxMetaController(storage);
|
||||
});
|
||||
|
||||
testWidgets('main shell + detail + filter', (tester) async {
|
||||
FlutterError.onError = (d) => fail('${d.exceptionAsString()}\n${d.stack}');
|
||||
|
||||
// Inject remote txs via reflection-free path: use import cache if available
|
||||
// Force linked-ish data by saving bank cache
|
||||
await storage.saveBankCache(BankCache(
|
||||
status: const ConnectStatus(status: 'linked', linked: true),
|
||||
balance: const BankBalance(amount: 100, currency: 'EUR'),
|
||||
transactions: [
|
||||
tx('Salary ACME', 2000),
|
||||
tx('Transfer to savings', -500),
|
||||
tx('Transfer from savings', 500),
|
||||
tx('Grocery', -40),
|
||||
],
|
||||
));
|
||||
bank = BankController(storage: storage);
|
||||
await bank.refresh(); // may fail network; cache should still load on construct
|
||||
// reconstruct to load cache
|
||||
bank = BankController(storage: storage);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
|
||||
ChangeNotifierProvider<TxMetaController>.value(value: meta),
|
||||
ChangeNotifierProvider<BankController>.value(value: bank),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: const MainShell(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// Open filter via transactions - go to a route
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
|
||||
ChangeNotifierProvider<TxMetaController>.value(value: meta),
|
||||
ChangeNotifierProvider<BankController>.value(value: bank),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: Builder(builder: (context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await showTransactionFilterDialog(
|
||||
context,
|
||||
available: meta.allLabels,
|
||||
transactions: bank.transactions,
|
||||
initial: const TransactionFilterSelection(labelIds: {}),
|
||||
);
|
||||
},
|
||||
child: const Text('OpenFilter'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('OpenFilter'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Filter transactions'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// Detail
|
||||
final sample = bank.transactions.isNotEmpty
|
||||
? bank.transactions.first
|
||||
: tx('x', 1);
|
||||
await meta.setInternalTransfer(sample, true);
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
|
||||
ChangeNotifierProvider<TxMetaController>.value(value: meta),
|
||||
ChangeNotifierProvider<BankController>.value(value: bank),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: TransactionDetailScreen(tx: sample),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Internal transfer'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:myapp/models/bank.dart';
|
||||
import 'package:myapp/providers/tx_meta_provider.dart';
|
||||
import 'package:myapp/services/storage_service.dart';
|
||||
import 'package:myapp/utils/app_theme.dart';
|
||||
import 'package:myapp/widgets/label_filter_dialog.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('filter sheet opens without layout/null errors', (tester) async {
|
||||
FlutterError.onError = (d) => fail(d.exceptionAsString());
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final storage = StorageService();
|
||||
await storage.init();
|
||||
final meta = TxMetaController(storage);
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||
addTearDown(() async {
|
||||
tester.view.resetViewInsets();
|
||||
await tester.binding.setSurfaceSize(null);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: meta,
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
showTransactionFilterDialog(
|
||||
context,
|
||||
available: meta.allLabels,
|
||||
transactions: const [
|
||||
BankTransaction(
|
||||
date: '2026-09-01',
|
||||
description: 'Test',
|
||||
amount: -12.5,
|
||||
currency: 'EUR',
|
||||
pending: false,
|
||||
),
|
||||
],
|
||||
initial: const TransactionFilterSelection(labelIds: {}),
|
||||
);
|
||||
},
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Filter transactions'), findsOneWidget);
|
||||
expect(find.text('Search text'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// Labels live further down a lazy ListView — scroll them into view.
|
||||
await tester.drag(
|
||||
find.byType(ListView).first,
|
||||
const Offset(0, -400),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Labels'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// Simulate keyboard: viewInsets must not collapse the sheet layout.
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Filter transactions'), findsOneWidget);
|
||||
expect(find.text('Apply'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:myapp/providers/auth_provider.dart';
|
||||
import 'package:myapp/providers/bank_provider.dart';
|
||||
import 'package:myapp/providers/theme_provider.dart';
|
||||
import 'package:myapp/providers/tx_meta_provider.dart';
|
||||
import 'package:myapp/screens/overview_screen.dart';
|
||||
import 'package:myapp/screens/transactions_screen.dart';
|
||||
import 'package:myapp/services/storage_service.dart';
|
||||
import 'package:myapp/utils/app_config.dart';
|
||||
import 'package:myapp/utils/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late StorageService storage;
|
||||
late AuthController auth;
|
||||
late BankController bank;
|
||||
late TxMetaController meta;
|
||||
|
||||
setUp(() async {
|
||||
AppConfig.loadForTest();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
storage = StorageService();
|
||||
await storage.init();
|
||||
auth = AuthController(storage);
|
||||
await auth.init();
|
||||
bank = BankController(storage: storage);
|
||||
// seed a couple txs
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
meta = TxMetaController(storage);
|
||||
});
|
||||
|
||||
testWidgets('overview builds dark', (tester) async {
|
||||
FlutterError.onError = (details) {
|
||||
fail(details.toString());
|
||||
};
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
|
||||
ChangeNotifierProvider<TxMetaController>.value(value: meta),
|
||||
ChangeNotifierProvider<BankController>.value(value: bank),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: const OverviewScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Overview'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('transactions builds dark', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
|
||||
ChangeNotifierProvider<TxMetaController>.value(value: meta),
|
||||
ChangeNotifierProvider<BankController>.value(value: bank),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: const TransactionsScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:myapp/utils/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('themes build and render SegmentedButton', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 0, label: Text('A')),
|
||||
ButtonSegment(value: 1, label: Text('B')),
|
||||
],
|
||||
selected: const {0},
|
||||
onSelectionChanged: (_) {},
|
||||
),
|
||||
NavigationBar(
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
|
||||
NavigationDestination(icon: Icon(Icons.star), label: 'Star'),
|
||||
],
|
||||
selectedIndex: 1,
|
||||
onDestinationSelected: (_) {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('A'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:myapp/main.dart';
|
||||
import 'package:myapp/providers/auth_provider.dart';
|
||||
import 'package:myapp/models/bank.dart';
|
||||
import 'package:myapp/models/transaction_meta.dart';
|
||||
import 'package:myapp/services/storage_service.dart';
|
||||
import 'package:myapp/utils/app_config.dart';
|
||||
import 'package:myapp/utils/constants.dart';
|
||||
import 'package:myapp/utils/helpers.dart';
|
||||
import 'package:myapp/utils/look_ahead.dart';
|
||||
@@ -13,10 +15,13 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<void> pumpApp(WidgetTester tester) async {
|
||||
AppConfig.loadForTest();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final storage = StorageService();
|
||||
await storage.init();
|
||||
await tester.pumpWidget(MyApp(storage: storage));
|
||||
final auth = AuthController(storage);
|
||||
await auth.init();
|
||||
await tester.pumpWidget(MyApp(storage: storage, auth: auth));
|
||||
}
|
||||
|
||||
testWidgets('splash shows the app name then opens Home', (tester) async {
|
||||
|
||||
Reference in New Issue
Block a user