Files
sure/mobile/lib/screens/settings_screen.dart
Lazy Bone fdc2ce1feb Add category support to transactions (#1251)
* Move debug logs button from Home to Settings page, remove refresh/logout from Home AppBar

- Remove Debug Logs, Refresh, and Sign Out buttons from DashboardScreen AppBar
- Add Debug Logs ListTile entry in SettingsScreen under app info section
- Remove unused _handleLogout method from DashboardScreen
- Remove unused log_viewer_screen.dart import from DashboardScreen

https://claude.ai/code/session_017XQZdaEwUuRS75tJMcHzB9

* Add category picker to Android transaction form

Implements category selection when creating transactions in the mobile app.
Uses the existing /api/v1/categories endpoint to fetch categories and sends
category_id when creating transactions via the API.

New files:
- Category model, CategoriesService, CategoriesProvider
Updated:
- Transaction/OfflineTransaction models with categoryId/categoryName
- TransactionsService/Provider to pass category_id
- DB schema v2 migration for category columns
- TransactionFormScreen with category dropdown in "More" section

Closes #78

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

* Fix ambiguous Category import in CategoriesProvider

Hide Flutter's built-in Category annotation from foundation.dart
to resolve name collision with our Category model.

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

* Add category filter on Dashboard, clear categories on data reset, fix ambiguous imports

- Add CategoryFilter widget (horizontal chip row like CurrencyFilter)
- Show category filter on Dashboard below currency filter (2nd row)
- Add "Show Category Filter" toggle in Settings > Display section
- Clear CategoriesProvider on "Clear Local Data" and "Reset Account"
- Fix Category name collision: hide Flutter's Category from material.dart
- Add getShowCategoryFilter/setShowCategoryFilter to PreferencesService

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

* Fix Category name collision using prefixed imports

Use 'import as models' instead of 'hide Category' to avoid
undefined_hidden_name warnings with flutter/material.dart.

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

* Fix duplicate column error in SQLite migration

Check if category_id/category_name columns exist before running
ALTER TABLE, preventing crashes when the DB was already at v2
or the migration had partially succeeded.

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

* Move CategoryFilter from dashboard to transaction list screen

CategoryFilter was filtering accounts on the dashboard but accounts
are already grouped by type. Moved it to TransactionsListScreen where
it filters transactions by category, which is the correct placement.

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

* Add category tag badge next to transaction name

Shows an oval-bordered category label after each transaction's
name for quick visual identification of transaction types.

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

* Address review findings for category feature

1. Category.fromJson now recursively parses parent chain;
   displayName walks all ancestors (e.g. "Grandparent > Parent > Child")
2. CategoriesProvider.fetchCategories guards against concurrent/duplicate
   calls by checking _isLoading and _hasFetched early
3. CategoryFilter chips use displayName to distinguish subcategories
4. Transaction badge resolves full displayName from CategoriesProvider
   with overflow ellipsis for long paths
5. Offline storage preserves local category values when server response
   omits them (coalesce with ??)

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

* Fix missing closing brace in PreferencesService causing theme_provider analyze errors

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

* Fix sync category upload, empty-state refresh, badge reactivity, and preferences syntax

- Add categoryId to SyncService pending transaction upload payload
- Replace non-scrollable Center with ListView for empty filter state so
  RefreshIndicator works when no transactions match
- Use listen:true for CategoriesProvider in badge display so badges
  rebuild when categories finish loading
- Fix missing closing brace in PreferencesService.setShowCategoryFilter

https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-13 20:01:08 +02:00

525 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../providers/auth_provider.dart';
import '../providers/categories_provider.dart';
import '../providers/theme_provider.dart';
import '../services/offline_storage_service.dart';
import '../services/log_service.dart';
import '../services/preferences_service.dart';
import '../services/user_service.dart';
import 'log_viewer_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _groupByType = false;
String? _appVersion;
bool _isResettingAccount = false;
bool _isDeletingAccount = false;
@override
void initState() {
super.initState();
_loadPreferences();
_loadAppVersion();
}
Future<void> _loadAppVersion() async {
final packageInfo = await PackageInfo.fromPlatform();
if (mounted) {
final build = packageInfo.buildNumber;
final display = build.isNotEmpty
? '${packageInfo.version} (${build})'
: packageInfo.version;
setState(() => _appVersion = display);
}
}
Future<void> _loadPreferences() async {
final groupByType = await PreferencesService.instance.getGroupByType();
if (mounted) {
setState(() {
_groupByType = groupByType;
});
}
}
Future<void> _handleClearLocalData(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Local Data'),
content: const Text(
'This will delete all locally cached transactions and accounts. '
'Your data on the server will not be affected. '
'Are you sure you want to continue?'
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Clear Data'),
),
],
),
);
if (confirmed == true && context.mounted) {
try {
final offlineStorage = OfflineStorageService();
final log = LogService.instance;
log.info('Settings', 'Clearing all local data...');
await offlineStorage.clearAllData();
if (context.mounted) {
Provider.of<CategoriesProvider>(context, listen: false).clear();
}
log.info('Settings', 'Local data cleared successfully');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Local data cleared successfully. Pull to refresh to sync from server.'),
backgroundColor: Colors.green,
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
final log = LogService.instance;
log.error('Settings', 'Failed to clear local data: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to clear local data: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
}
Future<void> _launchContactUrl(BuildContext context) async {
final uri = Uri.parse('https://discord.com/invite/36ZGBsxYEK');
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unable to open link')),
);
}
}
Future<void> _handleResetAccount(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset Account'),
content: const Text(
'Resetting your account will delete all your accounts, categories, '
'merchants, tags, and other data, but keep your user account intact.\n\n'
'This action cannot be undone. Are you sure?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Reset Account'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
setState(() => _isResettingAccount = true);
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final accessToken = await authProvider.getValidAccessToken();
if (accessToken == null) {
await authProvider.logout();
return;
}
final result = await UserService().resetAccount(accessToken: accessToken);
if (!context.mounted) return;
if (result['success'] == true) {
await OfflineStorageService().clearAllData();
if (context.mounted) {
Provider.of<CategoriesProvider>(context, listen: false).clear();
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Account reset has been initiated. This may take a moment.'),
backgroundColor: Colors.green,
),
);
await authProvider.logout();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['error'] ?? 'Failed to reset account'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isResettingAccount = false);
}
}
Future<void> _handleDeleteAccount(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Account'),
content: const Text(
'Deleting your account will permanently remove all your data '
'and cannot be undone.\n\n'
'Are you sure you want to delete your account?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete Account'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
setState(() => _isDeletingAccount = true);
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final accessToken = await authProvider.getValidAccessToken();
if (accessToken == null) {
await authProvider.logout();
return;
}
final result = await UserService().deleteAccount(accessToken: accessToken);
if (!context.mounted) return;
if (result['success'] == true) {
await authProvider.logout();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['error'] ?? 'Failed to delete account'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isDeletingAccount = false);
}
}
Future<void> _handleLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sign Out'),
content: const Text('Are you sure you want to sign out?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Sign Out'),
),
],
),
);
if (confirmed == true && context.mounted) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
await authProvider.logout();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final authProvider = Provider.of<AuthProvider>(context);
return Scaffold(
body: ListView(
children: [
// User info section
Container(
padding: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 30,
backgroundColor: colorScheme.primary,
child: Text(
authProvider.user?.displayName[0].toUpperCase() ?? 'U',
style: TextStyle(
fontSize: 24,
color: colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
authProvider.user?.displayName ?? 'User',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
authProvider.user?.email ?? '',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
],
),
),
),
),
// App version
ListTile(
leading: const Icon(Icons.info_outline),
title: Text('App Version: ${_appVersion ?? ''}'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(' > ui_layout: ${authProvider.user?.uiLayout}'),
Text(' > ai_enabled: ${authProvider.user?.aiEnabled}'),
],
),
),
ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: const Text('Contact us'),
subtitle: Text(
'https://discord.com/invite/36ZGBsxYEK',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
),
onTap: () => _launchContactUrl(context),
),
Semantics(
label: 'Open debug logs',
button: true,
child: ListTile(
leading: const Icon(Icons.bug_report),
title: const Text('Debug Logs'),
subtitle: const Text('View app diagnostic logs'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const LogViewerScreen()),
);
},
),
),
const Divider(),
// Display Settings Section
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Display',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
SwitchListTile(
secondary: const Icon(Icons.view_list),
title: const Text('Group by Account Type'),
subtitle: const Text('Group accounts by type (Crypto, Bank, etc.)'),
value: _groupByType,
onChanged: (value) async {
await PreferencesService.instance.setGroupByType(value);
setState(() {
_groupByType = value;
});
},
),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: const Text('Theme'),
trailing: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode, size: 18),
tooltip: 'Light',
),
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.brightness_auto, size: 18),
tooltip: 'System',
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode, size: 18),
tooltip: 'Dark',
),
],
selected: {themeProvider.themeMode},
onSelectionChanged: (modes) => themeProvider.setThemeMode(modes.first),
showSelectedIcon: false,
),
);
},
),
const Divider(),
// Data Management Section
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Data Management',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
// Clear local data button
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Clear Local Data'),
subtitle: const Text('Remove all cached transactions and accounts'),
onTap: () => _handleClearLocalData(context),
),
const Divider(),
// Danger Zone Section
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Danger Zone',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
),
ListTile(
leading: const Icon(Icons.restart_alt, color: Colors.red),
title: const Text('Reset Account'),
subtitle: const Text(
'Delete all accounts, categories, merchants, and tags but keep your user account',
),
trailing: _isResettingAccount
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: null,
enabled: !_isResettingAccount && !_isDeletingAccount,
onTap: _isResettingAccount || _isDeletingAccount ? null : () => _handleResetAccount(context),
),
ListTile(
leading: const Icon(Icons.delete_forever, color: Colors.red),
title: const Text('Delete Account'),
subtitle: const Text(
'Permanently remove all your data. This cannot be undone.',
),
trailing: _isDeletingAccount
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: null,
enabled: !_isDeletingAccount && !_isResettingAccount,
onTap: _isDeletingAccount || _isResettingAccount ? null : () => _handleDeleteAccount(context),
),
const Divider(),
// Sign out button
Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
onPressed: () => _handleLogout(context),
icon: const Icon(Icons.logout),
label: const Text('Sign Out'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
),
),
),
],
),
);
}
}