Feat: Macht die Kontoliste und Versionsanzeige funktional

This commit is contained in:
2025-12-25 23:15:54 +01:00
parent 7916161e4f
commit a45169bf12
11 changed files with 488 additions and 326 deletions

View File

@@ -49,48 +49,152 @@ class AccountController {
}
static final AccountController _instance = AccountController._internal();
BuildContext? _buildContext;
final AccountRepository _accountRepository = AccountRepository();
DynamicDialog? _newAccountDialog;
DynamicDialog? _errorNameEmptyDialog;
DynamicDialog? _accountCreatedDialog;
Account? _selected;
final ValueNotifier<Account?> _selected = ValueNotifier<Account?>(null);
/// Stellt das ausgewählte Konto dar, das angezeigt wird
Future<Account?> get selected async => _selected ??= (await getAccounts())[0];
ValueNotifier<Account?> get selected {
if (_selected.value == null) {
unawaited(
updateAccounts().then((_) {
_selected.value = accounts.value.firstOrNull;
}),
);
}
return _selected;
}
set selected(final Account selected) {
_selected = selected;
_selected.value = selected;
}
final ValueNotifier<List<Account>> _accounts = ValueNotifier<List<Account>>(
[],
);
/// Stellt die Liste der Konten dar
ValueNotifier<List<Account>> get accounts {
if (_accounts.value == []) {
unawaited(updateAccounts());
}
return _accounts;
}
set accounts(final List<Account> accounts) {
_accounts.value = accounts;
}
/// Gibt die gespeicherten Konten als Liste zurück
Future<List<Account>> getAccounts() async {
Future<void> updateAccounts() async {
final List<Account> accounts = await _accountRepository.findBy(
orderBy: 'nameAsc',
);
return accounts;
_accounts.value = accounts;
}
/// Startet den Prozess um ein neues Konto anzulegen
void newAccountHandler(final BuildContext buildContext) {
_buildContext = buildContext;
unawaited(_newAccountDialog?.show(buildContext));
/// Startet den Prozess, um ein neues Konto anzulegen
void newAccountHandler() {
unawaited(_newAccountDialog?.show());
}
Future<void> _saveNewAccount(final Map<String, String> values) async {
if (values['name'] == null || values['name']!.isEmpty) {
if (_buildContext != null) {
await _errorNameEmptyDialog?.show(_buildContext!);
}
/// Startet den Prozess, um ein Konto umzubenennen
Future<void> renameAccountHandler(final int accountId) async {
final Account? account = await _accountRepository.find(accountId);
if (account != null) {
final renameAccountDialog = DynamicDialog(
title: '${account.name} umbenennen',
icon: Icons.edit,
inputFields: [
DialogInputField(
id: 'name',
label: 'Name',
initialValue: account.name,
autoFocus: true,
),
],
actions: [
DialogAction(label: 'Abbruch'),
DialogAction(
label: 'Speichern',
isPrimary: true,
onPressed: _renameAccount,
),
],
hiddenValues: {'account': account},
);
unawaited(renameAccountDialog.show());
}
}
/// Startet den Prozess, um ein Konto zu löschen
Future<void> deleteAccountHandler(final int accountId) async {
final Account? account = await _accountRepository.find(accountId);
if (account != null) {
final deleteAccountDialog = DynamicDialog(
dialogType: DialogTypeEnum.error,
title: '${account.name} löschen',
content: Text('Willst du ${account.name} wirklich löschen?'),
icon: Icons.delete_forever,
actions: [
DialogAction(label: 'Abbruch', isPrimary: true),
DialogAction(label: 'Account löschen', onPressed: _deleteAccount),
],
hiddenValues: {'account': account},
);
unawaited(deleteAccountDialog.show());
}
}
Future<void> _saveNewAccount(final Map<String, dynamic> values) async {
if (values['name'] == null || values['name'] == '') {
await _errorNameEmptyDialog?.show();
} else {
final account = AccountsCompanion(name: Value(values['name']!));
await _accountRepository.add(account);
await _accountCreatedDialog?.show(_buildContext!);
await _accountCreatedDialog?.show();
await updateAccounts();
}
}
Future<void> _renameAccount(final Map<String, dynamic> values) async {
if (values['account'] != null && values['name'] != null) {
final Account account = values['account'];
final acc = AccountsCompanion(
id: Value(account.id),
name: Value(values['name']),
);
await _accountRepository.update(acc);
await updateAccounts();
if (account == selected.value) {
selected.value = await _accountRepository.find(account.id);
}
}
}
Future<void> _deleteAccount(final Map<String, dynamic> values) async {
if (values['account'] != null) {
final Account account = values['account'];
await _accountRepository.remove(account);
await updateAccounts();
if (account == _selected.value) {
_selected.value = _accounts.value.firstOrNull;
}
}
}
}

View File

@@ -0,0 +1,11 @@
/// Ein Listenitem, verwendet um editierbare Listen zu erstellen
class ListItem {
/// Erstellt eine neue Instanz dieser Klasse
ListItem({required this.id, required this.name});
/// Die Id des Eintrags
int id;
/// Der Name des Eintrags
String name;
}

View File

@@ -10,5 +10,5 @@ class DialogAction {
final bool isPrimary;
/// Was bei einem Knopfdruck passieren soll
final void Function(Map<String, String> inputValues)? onPressed;
final void Function(Map<String, dynamic> inputValues)? onPressed;
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../Entities/dialog_type_enum.dart';
import '../../Services/navigation_service.dart';
import '../../Services/theme_service.dart';
import 'dialog_action.dart';
import 'dialog_input_field.dart';
@@ -18,6 +19,7 @@ class DynamicDialog {
this.borderRadius = 16,
this.barrierDismissible = true,
this.dialogType = DialogTypeEnum.info,
this.hiddenValues
}) : inputFields = inputFields ?? const [],
actions = actions ?? [DialogAction(label: 'Schließen')];
@@ -48,6 +50,9 @@ class DynamicDialog {
/// Der Typ des Dialogs
final DialogTypeEnum dialogType;
/// Versteckte Werte, die beim Abschicken mit zurückgegeben werden
final Map<String, dynamic>? hiddenValues;
Map<String, TextEditingController>? _controllers;
Map<String, FocusNode>? _focusNodes;
@@ -79,119 +84,133 @@ class DynamicDialog {
}
/// Zeigt den vorher zusammengebauten Dialog an
Future<void> show(final BuildContext context) async {
final ThemeData theme = Theme.of(context);
Future<void> show() async {
final BuildContext? context = NavigationService.getCurrentBuildContext();
_prepareControllers();
_prepareFocusNodes();
if (context != null) {
final ThemeData theme = Theme.of(context);
await showDialog(
context: context,
barrierDismissible: barrierDismissible,
builder: (final BuildContext ctx) {
_dialogContext = ctx;
WidgetsBinding.instance.addPostFrameCallback((_) {
final DialogInputField? autoFocusField = inputFields
.where((final DialogInputField f) => f.autoFocus)
.cast<DialogInputField?>()
.firstWhere(
(final DialogInputField? f) => f != null,
orElse: () => null,
);
_prepareControllers();
_prepareFocusNodes();
if (autoFocusField != null) {
_focusNodes![autoFocusField.id]!.requestFocus();
}
});
await showDialog(
context: context,
barrierDismissible: barrierDismissible,
builder: (final BuildContext ctx) {
_dialogContext = ctx;
WidgetsBinding.instance.addPostFrameCallback((_) {
final DialogInputField? autoFocusField = inputFields
.where((final DialogInputField f) => f.autoFocus)
.cast<DialogInputField?>()
.firstWhere(
(final DialogInputField? f) => f != null,
orElse: () => null,
);
final DialogAction primaryAction = actions.firstWhere(
(final a) => a.isPrimary,
orElse: () => actions.first,
);
if (autoFocusField != null) {
_focusNodes![autoFocusField.id]!.requestFocus();
}
});
Color backgroundColor =
this.backgroundColor ?? theme.colorScheme.surface;
if (dialogType == DialogTypeEnum.error) {
backgroundColor = theme.colorScheme.errorContainer;
} else if (dialogType == DialogTypeEnum.success) {
backgroundColor = ThemeService.getSuccessColor(
brightness: theme.brightness,
final DialogAction primaryAction = actions.firstWhere(
(final a) => a.isPrimary,
orElse: () => actions.first,
);
}
return AlertDialog(
backgroundColor: backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius),
),
title: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Icon(icon, size: 48, color: theme.colorScheme.primary),
if (title != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
title!,
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (content != null) content!,
...inputFields.map(
(final DialogInputField field) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextField(
controller: _controllers![field.id],
focusNode: _focusNodes![field.id],
keyboardType: field.keyboardType,
obscureText: field.obscureText,
onChanged: field.onChanged,
decoration: InputDecoration(
labelText: field.label,
border: const OutlineInputBorder(),
isDense: true,
Color backgroundColor =
this.backgroundColor ?? theme.colorScheme.surface;
if (dialogType == DialogTypeEnum.error) {
backgroundColor = theme.colorScheme.errorContainer;
} else if (dialogType == DialogTypeEnum.success) {
backgroundColor = ThemeService.getSuccessColor(
brightness: theme.brightness,
);
}
return AlertDialog(
backgroundColor: backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius),
),
title: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Icon(icon, size: 48, color: theme.colorScheme.primary),
if (title != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
title!,
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
onSubmitted: (_) {
final Map<String, String> values = {
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (content != null) content!,
...inputFields.map(
(final DialogInputField field) =>
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextField(
controller: _controllers![field.id],
focusNode: _focusNodes![field.id],
keyboardType: field.keyboardType,
obscureText: field.obscureText,
onChanged: field.onChanged,
decoration: InputDecoration(
labelText: field.label,
border: const OutlineInputBorder(),
isDense: true,
),
onSubmitted: (_) {
final Map<String, dynamic> values = {
for (final entry in _controllers!.entries)
entry.key: entry.value.text,
};
hiddenValues?.forEach((final key, final value) {
values[key] = value;
});
close();
primaryAction.onPressed?.call(values);
},
),
),
),
],
),
actions: actions
.map(
(final action) =>
TextButton(
onPressed: () {
final Map<String, dynamic> values = {
for (final entry in _controllers!.entries)
entry.key: entry.value.text,
};
close();
primaryAction.onPressed?.call(values);
},
),
),
),
],
),
actions: actions
.map(
(final action) => TextButton(
onPressed: () {
final Map<String, String> values = {
for (final entry in _controllers!.entries)
entry.key: entry.value.text,
};
hiddenValues?.forEach((final key, final value) {
values[key] = value;
});
close();
action.onPressed?.call(values);
},
child: Text(action.label),
),
)
.toList(),
);
},
);
close();
action.onPressed?.call(values);
},
child: Text(action.label),
),
)
.toList(),
);
},
);
}
}
/// Schließt den dynamischen Dialog

View File

@@ -15,52 +15,59 @@ class AccountSelect extends StatefulWidget {
class _AccountSelectState extends State<AccountSelect> {
final AccountController _accountController = AccountController();
Account? _selected;
List<Account> _accounts = [];
@override
void initState() {
super.initState();
_selected = _accountController.selected.value;
_accounts = _accountController.accounts.value;
_accountController.selected.addListener(() {
setState(() {
if (context.mounted) {
_selected = _accountController.selected.value;
}
});
});
_accountController.accounts.addListener(() {
setState(() {
if (context.mounted) {
_accounts = _accountController.accounts.value;
}
});
});
}
@override
Widget build(final BuildContext context) {
final Future<Account?> selected = _accountController.selected;
if (_selected != null && _accounts != []) {
return DropdownSearch<Account>(
items: (final f, final cs) => _accounts,
selectedItem: _selected,
onChanged: (final Account? account) {
if (account != null) {
_accountController.selected = account;
}
},
return FutureBuilder(
future: selected,
builder:
(final BuildContext context, final AsyncSnapshot<Account?> snapshot) {
if (snapshot.hasData) {
return DropdownSearch<Account>(
items: (final f, final cs) => _accountController.getAccounts(),
selectedItem: snapshot.data,
onChanged: (final Account? account) {
if (account != null) {
_accountController.selected = account;
}
},
itemAsString: (final Account account) => account.name,
compareFn: (final Account a1, final Account a2) =>
a1.id == a2.id,
popupProps: const PopupProps<Account>.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration: InputDecoration(
hintText: 'Konto suchen...',
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
),
);
} else if (snapshot.hasError) {
return const Row(
children: [
Icon(Icons.error, color: Colors.red),
Text('Fehler beim Laden der Konten!'),
],
);
} else {
return const CircularProgressIndicator();
}
},
);
itemAsString: (final Account account) => account.name,
compareFn: (final Account a1, final Account a2) => a1.id == a2.id,
popupProps: const PopupProps<Account>.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration: InputDecoration(
hintText: 'Konto suchen...',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
),
);
} else {
return const CircularProgressIndicator();
}
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../../Entities/list_item.dart';
/// Eine editierbare Liste mit Funktionen zur Bearbeitung
class EditableList extends StatelessWidget {
/// Erstellt eine neue Instanz dieser Klasse
const EditableList({
required this.name,
required this.items,
required this.onAdd,
required this.onRename,
required this.onDelete,
this.icon,
this.addTooltip,
this.menuTooltip,
super.key,
});
/// Der Name, der oben angezeigt wird
final String name;
/// Die
final List<ListItem> items;
/// Die Funktion, die aufgerufen wird,
/// wenn ein neuer Eintrag hinzugefügt werden soll
final void Function() onAdd;
/// Die Funktion, die beim umbenennen aufgerufen wird
final void Function(int) onRename;
///Die Funktion, die beim Löschen aufgerufen wird
final void Function(int) onDelete;
/// Das Icon, das angezeigt wird
final Icon? icon;
/// Der Tooltip, der beim erstellen-Button angezeigt wird
final String? addTooltip;
/// Der Tooltip, der auf dem Menü angezeigt wird
final String? menuTooltip;
@override
Widget build(final BuildContext context) => Expanded(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
name,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
onPressed: onAdd,
icon: const Icon(Icons.add),
tooltip: addTooltip,
),
],
),
const SizedBox(height: 8),
Expanded(
child: ListView.separated(
itemCount: items.length,
separatorBuilder: (_, _) => const Divider(),
itemBuilder: (final context, final index) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(items[index].name),
leading: icon,
trailing: PopupMenuButton<String>(
tooltip: menuTooltip,
onSelected: (final value) {
if (value == 'rename') {
onRename(items[index].id);
} else if (value == 'delete') {
onDelete(items[index].id);
}
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'rename', child: Text('Umbenennen')),
PopupMenuItem(value: 'delete', child: Text('Entfernen')),
],
),
),
),
),
],
),
);
}

View File

@@ -33,9 +33,7 @@ class _FloatingCreationButtonState extends State<FloatingCreationButton> {
_expandableButton(
label: 'Neues Konto',
icon: Icons.account_balance_wallet,
onPressed: () {
_accountController.newAccountHandler(context);
},
onPressed: _accountController.newAccountHandler,
),
],
);

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../../Controller/account_controller.dart';
import '../../Entities/drift_database.dart';
import '../../Entities/list_item.dart';
import '../Misc/editable_list.dart';
class AccountList extends StatefulWidget {
const AccountList({super.key});
@override
State<StatefulWidget> createState() => _AccountListState();
}
class _AccountListState extends State<AccountList> {
final AccountController _accountController = AccountController();
List<Account> _accounts = [];
@override
void initState() {
super.initState();
_accounts = _accountController.accounts.value;
_accountController.accounts.addListener(() {
setState(() {
if (context.mounted) {
_accounts = _accountController.accounts.value;
}
});
});
}
@override
Widget build(final BuildContext context) {
if (_accounts != []) {
final List<ListItem> formatedAccounts = [];
for (final Account data in _accounts) {
formatedAccounts.add(ListItem(id: data.id, name: data.name));
}
return EditableList(
name: 'Konten',
items: formatedAccounts,
onAdd: _accountController.newAccountHandler,
onRename: _accountController.renameAccountHandler,
onDelete: _accountController.deleteAccountHandler,
icon: const Icon(Icons.account_balance_wallet),
addTooltip: 'Konto hinzufügen',
menuTooltip: 'Menü anzeigen',
);
} else {
return const Center(child: CircularProgressIndicator());
}
}
}

View File

@@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'account_list.dart';
/// Eine Widget-Klasse, die die Einstellungsseite der Anwendung darstellt.
class Settings extends StatefulWidget {
@@ -10,110 +13,6 @@ class Settings extends StatefulWidget {
}
class _SettingsState extends State<Settings> {
final List<String> _accounts = <String>[
'Girokonto',
'Sparkonto',
'Kreditkarte',
];
Future<void> _addAccount() async {
final controller = TextEditingController();
await showDialog(
context: context,
builder: (final BuildContext context) => AlertDialog(
title: const Text('Konto hinzufügen'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Kontoname',
border: OutlineInputBorder(),
),
autofocus: true,
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () {
if (controller.text.isNotEmpty) {
setState(() => _accounts.add(controller.text));
}
Navigator.pop(context);
},
child: const Text('Hinzufügen'),
),
],
),
);
}
Future<void> _renameAccount(final int index) async {
final controller = TextEditingController(
text: _accounts[index],
);
await showDialog(
context: context,
builder: (final BuildContext context) => AlertDialog(
title: const Text('Konto umbenennen'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Neuer Name',
border: OutlineInputBorder(),
),
autofocus: true,
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () {
if (controller.text.isNotEmpty) {
setState(() => _accounts[index] = controller.text);
}
Navigator.pop(context);
},
child: const Text('Speichern'),
),
],
),
);
}
Future<void> _removeAccount(final int index) async {
await showDialog(
context: context,
builder: (final BuildContext context) => AlertDialog(
title: const Text('Konto entfernen'),
content: Text(
'Möchtest du das Konto "${_accounts[index]}" wirklich löschen?',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () {
setState(() => _accounts.removeAt(index));
Navigator.pop(context);
},
child: const Text('Löschen'),
),
],
),
);
}
@override
Widget build(final BuildContext context) {
final ThemeData theme = Theme.of(context);
@@ -129,12 +28,9 @@ class _SettingsState extends State<Settings> {
'Einstellungen',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
_accountHeader(),
const SizedBox(height: 8),
_accountList(),
const AccountList(),
const SizedBox(height: 8),
_versionNumber(theme),
@@ -145,60 +41,32 @@ class _SettingsState extends State<Settings> {
);
}
Widget _accountHeader() => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const Text(
'Konten',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
onPressed: _addAccount,
icon: const Icon(Icons.add),
tooltip: 'Konto hinzufügen',
),
],
);
Widget _versionNumber(final ThemeData theme) {
final Future<PackageInfo> packageInfo = PackageInfo.fromPlatform();
Widget _accountList() => Expanded(
child: ListView.separated(
itemCount: _accounts.length,
separatorBuilder: (_, _) => const Divider(),
itemBuilder: (final BuildContext context, final int index) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(_accounts[index]),
leading: const Icon(Icons.account_balance_wallet),
trailing: PopupMenuButton<String>(
onSelected: (final String value) async {
if (value == 'rename') {
await _renameAccount(index);
} else if (value == 'delete') {
await _removeAccount(index);
}
},
itemBuilder: (final BuildContext context) =>
const <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'rename',
child: Text('Umbenennen'),
),
PopupMenuItem<String>(
value: 'delete',
child: Text('Entfernen'),
),
],
),
return Align(
alignment: Alignment.bottomLeft,
child: FutureBuilder(
future: packageInfo,
builder:
(
final BuildContext context,
final AsyncSnapshot<PackageInfo> snapshot,
) {
if (snapshot.hasData) {
return Text(
'${snapshot.data?.version}+${snapshot.data?.buildNumber}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(
(0.6 * 255).round(),
),
),
);
} else {
return const CircularProgressIndicator();
}
},
),
),
);
Widget _versionNumber(final ThemeData theme) => Align(
alignment: Alignment.bottomLeft,
child: Text(
'Version 0.0.0+0',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha((0.6 * 255).round()),
),
),
);
);
}
}

View File

@@ -13,6 +13,10 @@ class AccountRepository {
return find(id);
}
/// Aktualisiert ein Konto in der Datenbank
Future<bool> update(final AccountsCompanion account) =>
_db.update(_db.accounts).replace(account);
/// Entfernt ein Konto aus der Datenbank
Future<int> remove(final Account account) => (_db.delete(
_db.accounts,

View File

@@ -5,4 +5,7 @@ class NavigationService {
/// Der Navigator-Schlüssel, der für die Navigation verwendet wird
static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
/// Gibt den aktuell gültigen BuildContext zurück
static BuildContext? getCurrentBuildContext() => navigatorKey.currentContext;
}