diff --git a/finlog_app/app/ios/Flutter/Debug.xcconfig b/finlog_app/app/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/finlog_app/app/ios/Flutter/Debug.xcconfig +++ b/finlog_app/app/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/finlog_app/app/ios/Flutter/Release.xcconfig b/finlog_app/app/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/finlog_app/app/ios/Flutter/Release.xcconfig +++ b/finlog_app/app/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/finlog_app/app/lib/core/ui/panel.dart b/finlog_app/app/lib/core/ui/panel.dart new file mode 100644 index 0000000..62bb73a --- /dev/null +++ b/finlog_app/app/lib/core/ui/panel.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:animations/animations.dart'; + +/// Ermöglicht verschachtelte "Panels" ähnlich einem StackNavigator. +/// Beispiel: +/// ```dart +/// PanelNavigator( +/// rootBuilder: (_) => MyRootPanel(), +/// ) +/// ``` +class PanelNavigator extends StatefulWidget { + final WidgetBuilder rootBuilder; + + const PanelNavigator({super.key, required this.rootBuilder}); + + @override + State createState() => _PanelNavigatorState(); + + /// Zugriff per InheritedWidget / Extension + static PanelController of(BuildContext context) { + final inherited = context + .dependOnInheritedWidgetOfExactType<_PanelInherited>(); + assert(inherited != null, 'Kein PanelNavigator im Widget-Tree gefunden'); + return inherited!.controller; + } +} + +class _PanelNavigatorState extends State { + late final PanelController _controller; + + @override + void initState() { + super.initState(); + _controller = PanelController( + rootBuilder: widget.rootBuilder, + onUpdate: () => setState(() {}), + ); + } + + @override + Widget build(BuildContext context) { + return _PanelInherited( + controller: _controller, + child: PageTransitionSwitcher( + duration: const Duration(milliseconds: 300), + reverse: _controller.navDirection == _NavDirection.back, + transitionBuilder: (child, primary, secondary) { + return SharedAxisTransition( + animation: primary, + secondaryAnimation: secondary, + transitionType: SharedAxisTransitionType.horizontal, + fillColor: Colors.transparent, + child: child, + ); + }, + child: _controller.getCurrentPanel(context), + ), + ); + } +} + +class PanelController { + final WidgetBuilder rootBuilder; + final VoidCallback onUpdate; + + final List<_PanelEntry> _stack = []; + _NavDirection _navDirection = _NavDirection.forward; + int _version = 0; + + PanelController({required this.rootBuilder, required this.onUpdate}) { + _stack.add(_PanelEntry(title: null, builder: rootBuilder)); + } + + _NavDirection get navDirection => _navDirection; + + Widget getCurrentPanel(BuildContext context) { + final entry = _stack.last; + return KeyedSubtree( + key: ValueKey('panel_v${_version}_${_stack.length}'), + child: _PanelScaffold( + title: entry.title, + body: entry.builder(context), + onBack: canPop ? pop : null, + ), + ); + } + + bool get canPop => _stack.length > 1; + + void push(WidgetBuilder builder, {String? title}) { + _navDirection = _NavDirection.forward; + _stack.add(_PanelEntry(title: title, builder: builder)); + _version++; + onUpdate(); + } + + void pop() { + if (canPop) { + _navDirection = _NavDirection.back; + _stack.removeLast(); + _version++; + onUpdate(); + } + } +} + +enum _NavDirection { forward, back } + +class _PanelEntry { + final String? title; + final WidgetBuilder builder; + + _PanelEntry({this.title, required this.builder}); +} + +class _PanelScaffold extends StatelessWidget { + final String? title; + final Widget body; + final VoidCallback? onBack; + + const _PanelScaffold({required this.title, required this.body, this.onBack}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (title != null) PanelHeader(title: title!, onBack: onBack), + Expanded( + child: Padding(padding: const EdgeInsets.all(15), child: body), + ), + ], + ); + } +} + +class _PanelInherited extends InheritedWidget { + final PanelController controller; + + const _PanelInherited({required super.child, required this.controller}); + + @override + bool updateShouldNotify(_PanelInherited oldWidget) => + controller != oldWidget.controller; +} + +/// ---------- PUBLIC UI COMPONENTS ---------- + +class PanelHeader extends StatelessWidget { + final String title; + final VoidCallback? onBack; + + const PanelHeader({super.key, required this.title, this.onBack}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Material( + color: scheme.surface, + elevation: 1, + child: SizedBox( + height: 56, + child: Row( + children: [ + if (onBack != null) + IconButton(icon: const Icon(Icons.arrow_back), onPressed: onBack), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + ), + ); + } +} + +/// ---------- EXTENSIONS ---------- + +extension PanelContext on BuildContext { + PanelController get panels => PanelNavigator.of(this); +} diff --git a/finlog_app/app/lib/modules/settings/modules/app/app_settings_view.dart b/finlog_app/app/lib/modules/settings/modules/app/app_settings_view.dart new file mode 100644 index 0000000..eb6586a --- /dev/null +++ b/finlog_app/app/lib/modules/settings/modules/app/app_settings_view.dart @@ -0,0 +1,242 @@ +import 'package:app/modules/settings/modules/app/model/app_settings_view_model.dart'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class AppSettingsView extends StatelessWidget { + const AppSettingsView({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => AppSettingsViewModel()..load(), + child: const _AppSettingsContent(), + ); + } +} + +class _AppSettingsContent extends StatelessWidget { + const _AppSettingsContent(); + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + final isLoading = vm.isLoading; + + return Column( + children: [ + // const PanelHeader(title: 'App-Einstellungen'), + if (isLoading) + const Expanded(child: Center(child: CircularProgressIndicator())) + else + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _SystemBackgroundSection(), + const SizedBox(height: 16), + _TextSizeSection(), + const SizedBox(height: 16), + _LanguageSection(), + const SizedBox(height: 24), + _SaveButton(), + ], + ), + ), + ], + ); + } +} + +class _SystemBackgroundSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final vm = context.watch(); + final scheme = Theme.of(context).colorScheme; + + // A few pleasant presets; extend as needed. + final presets = [ + const Color(0xFFFFFFFF), + const Color(0xFFF5F5F5), + const Color(0xFF121212), + const Color(0xFF1E1E1E), + Colors.blueGrey.shade50, + Colors.blueGrey.shade900, + ]; + + final isSystem = vm.backgroundColorSystem == null; + final selected = vm.backgroundColorSystem; + + Widget chip({ + required Widget child, + required bool selected, + required VoidCallback onTap, + }) { + return InkWell( + borderRadius: BorderRadius.circular(22), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: selected ? scheme.primary : scheme.outlineVariant, + ), + ), + child: child, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'System-Hintergrundfarbe', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + chip( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.phone_android, size: 18), + const SizedBox(width: 6), + Text( + 'Systemstandard', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + selected: isSystem, + onTap: () => vm.setSystemBackgroundColor(null), + ), + ...presets.map((c) { + final bool sel = selected == c; + return GestureDetector( + onTap: () => vm.setSystemBackgroundColor(c), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: Border.all( + color: sel ? scheme.primary : scheme.outlineVariant, + width: sel ? 2 : 1, + ), + ), + ), + ); + }), + ], + ), + const SizedBox(height: 4), + Text( + isSystem + ? 'Aktuell: Systemstandard' + : 'Aktuell: Benutzerdefinierte Farbe', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: scheme.outline), + ), + ], + ); + } +} + +class _TextSizeSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final vm = context.watch(); + final selected = vm.textSize; + + Widget radio(AppTextSize size, String label, String sub) { + return RadioListTile( + value: size, + groupValue: selected, + onChanged: (v) => vm.setTextSize(v ?? AppTextSize.normal), + title: Text(label), + subtitle: Text(sub), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Textgröße', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + radio(AppTextSize.small, 'Klein', 'Kompakte Darstellung'), + radio(AppTextSize.normal, 'Standard', 'Empfohlene Einstellung'), + radio(AppTextSize.large, 'Groß', 'Größere, besser lesbare Texte'), + ], + ); + } +} + +class _LanguageSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final vm = context.watch(); + final scheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sprache', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + DropdownButtonFormField( + value: vm.language, + onChanged: (v) => vm.setLanguage(v ?? AppLanguage.de), + items: const [ + DropdownMenuItem(value: AppLanguage.de, child: Text('Deutsch')), + DropdownMenuItem(value: AppLanguage.en, child: Text('Englisch')), + ], + decoration: InputDecoration( + isDense: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 4), + Text( + 'Änderungen wirken sich nach dem Speichern aus.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: scheme.outline), + ), + ], + ); + } +} + +class _SaveButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return FilledButton.icon( + onPressed: vm.isSaving + ? null + : () async { + await vm.save(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Einstellungen gespeichert')), + ); + } + }, + icon: vm.isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Speichern'), + ); + } +} diff --git a/finlog_app/app/lib/modules/settings/modules/app/model/app_settings_view_model.dart b/finlog_app/app/lib/modules/settings/modules/app/model/app_settings_view_model.dart new file mode 100644 index 0000000..e395681 --- /dev/null +++ b/finlog_app/app/lib/modules/settings/modules/app/model/app_settings_view_model.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +/// Domain-ish enums for clarity and easy (de)serialization. +enum AppTextSize { small, normal, large } + +enum AppLanguage { de, en } + +class AppSettingsViewModel extends ChangeNotifier { + /// Use `null` to represent "system default background". + Color? _backgroundColorSystem; + AppTextSize _textSize = AppTextSize.normal; + AppLanguage _language = AppLanguage.de; + + bool _isLoading = false; + bool _isSaving = false; + + Color? get backgroundColorSystem => _backgroundColorSystem; + + AppTextSize get textSize => _textSize; + + AppLanguage get language => _language; + + bool get isLoading => _isLoading; + + bool get isSaving => _isSaving; + + /// Pretend to load from backend. Plug your repository here later. + Future load() async { + _isLoading = true; + notifyListeners(); + + // TODO: Replace with real backend call. + await Future.delayed(const Duration(milliseconds: 200)); + + // Example defaults (could come from server response). + _backgroundColorSystem = null; // null => Systemstandard + _textSize = AppTextSize.normal; + _language = AppLanguage.de; + + _isLoading = false; + notifyListeners(); + } + + /// Save to backend (stub). + Future save() async { + if (_isSaving) return; + _isSaving = true; + notifyListeners(); + + // TODO: Replace with real backend update. + await Future.delayed(const Duration(milliseconds: 300)); + + _isSaving = false; + notifyListeners(); + } + + void setSystemBackgroundColor(Color? colorOrNullForSystem) { + _backgroundColorSystem = colorOrNullForSystem; + notifyListeners(); + } + + void setTextSize(AppTextSize size) { + _textSize = size; + notifyListeners(); + } + + void setLanguage(AppLanguage lang) { + _language = lang; + notifyListeners(); + } + + Map toJson() => { + 'backgroundColorSystem': _backgroundColorSystem?.value, + // null => system + 'textSize': _textSize.name, + 'language': _language.name, + }; + + void fromJson(Map json) { + final int? colorValue = json['backgroundColorSystem'] as int?; + _backgroundColorSystem = colorValue != null ? Color(colorValue) : null; + + final ts = json['textSize'] as String?; + _textSize = AppTextSize.values.firstWhere( + (e) => e.name == ts, + orElse: () => AppTextSize.normal, + ); + + final lng = json['language'] as String?; + _language = AppLanguage.values.firstWhere( + (e) => e.name == lng, + orElse: () => AppLanguage.de, + ); + + notifyListeners(); + } +} diff --git a/finlog_app/app/lib/modules/settings/settings_view.dart b/finlog_app/app/lib/modules/settings/settings_view.dart index 1fba8fb..dee0f86 100644 --- a/finlog_app/app/lib/modules/settings/settings_view.dart +++ b/finlog_app/app/lib/modules/settings/settings_view.dart @@ -1,3 +1,5 @@ +import 'package:app/core/ui/panel.dart'; +import 'package:app/modules/settings/modules/app/app_settings_view.dart'; import 'package:flutter/material.dart'; class SettingsView extends StatelessWidget { @@ -5,6 +7,264 @@ class SettingsView extends StatelessWidget { @override Widget build(BuildContext context) { - return const Center(child: Text('Settings')); + return Scaffold( + appBar: AppBar(title: const Text('Einstellungen')), + body: PanelNavigator(rootBuilder: (ctx) => _CategoryList()), + ); + } +} + +/// ----------------- Root panel: Category list ----------------- +class _CategoryList extends StatelessWidget { + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + Widget tile(IconData icon, String label, Widget Function() detail) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => context.panels.push((_) => detail(), title: label), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(icon), + const SizedBox(width: 12), + Expanded(child: Text(label)), + Icon(Icons.chevron_right, color: scheme.outline), + ], + ), + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionHeader('App-Einstellungen'), + tile(Icons.tune, 'App-Einstellungen', () => const AppSettingsView()), + const SizedBox(height: 12), + + const _SectionHeader('Meine Daten'), + tile( + Icons.badge_outlined, + 'Persönliche Daten', + () => const _PersonalPanel(), + ), + tile( + Icons.manage_accounts_outlined, + 'Kontoverwaltung', + () => const _AccountPanel(), + ), + tile( + Icons.security_outlined, + 'Sicherheit', + () => const _SecurityPanel(), + ), + const SizedBox(height: 12), + + const _SectionHeader('Hilfe'), + tile(Icons.help_outline, 'Hilfe', () => const _HelpPanel()), + tile(Icons.feedback_outlined, 'Feedback', () => const _FeedbackPanel()), + tile( + Icons.gavel_outlined, + 'Rechtliches & Datenschutz', + () => const _LegalPanel(), + ), + const SizedBox(height: 24), + + const Divider(), + ListTile( + leading: const Icon(Icons.logout), + title: const Text('Abmelden'), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logout… (noch nicht implementiert)'), + ), + ); + }, + ), + ], + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String text; + + const _SectionHeader(this.text); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), + child: Text( + text, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: .2, + ), + ), + ); +} + +/// ----------------- Detail panels (unchanged) ----------------- + +class _PersonalPanel extends StatelessWidget { + const _PersonalPanel(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // const PanelHeader(title: 'Persönliche Daten'), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: const [ + ListTile( + leading: Icon(Icons.person_outline), + title: Text('Name'), + subtitle: Text('Max Mustermann'), + ), + ], + ), + ), + ], + ); + } +} + +class _AccountPanel extends StatelessWidget { + const _AccountPanel(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // const PanelHeader(title: 'Kontoverwaltung'), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: const [ + ListTile( + leading: Icon(Icons.alternate_email), + title: Text('E-Mail'), + subtitle: Text('max@example.com'), + ), + ], + ), + ), + ], + ); + } +} + +class _SecurityPanel extends StatelessWidget { + const _SecurityPanel(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // const PanelHeader(title: 'Sicherheit'), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: const [ + ListTile( + leading: Icon(Icons.lock_outline), + title: Text('Passwort ändern'), + ), + ListTile( + leading: Icon(Icons.phonelink_lock), + title: Text('2-Faktor-Authentifizierung'), + subtitle: Text('Aus'), + ), + ], + ), + ), + ], + ); + } +} + +class _HelpPanel extends StatelessWidget { + const _HelpPanel(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // const PanelHeader(title: 'Hilfe'), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: const [ + ListTile(leading: Icon(Icons.help_outline), title: Text('FAQ')), + ], + ), + ), + ], + ); + } +} + +class _FeedbackPanel extends StatelessWidget { + const _FeedbackPanel(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // const PanelHeader(title: 'Feedback'), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: const [ + ListTile( + leading: Icon(Icons.feedback_outlined), + title: Text('Feedback senden'), + ), + ], + ), + ), + ], + ); + } +} + +class _LegalPanel extends StatelessWidget { + const _LegalPanel(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // const PanelHeader(title: 'Rechtliches & Datenschutz'), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: const [ + ListTile( + leading: Icon(Icons.privacy_tip_outlined), + title: Text('Datenschutz'), + ), + ListTile( + leading: Icon(Icons.article_outlined), + title: Text('Nutzungsbedingungen'), + ), + ], + ), + ), + ], + ); } } diff --git a/finlog_app/app/pubspec.yaml b/finlog_app/app/pubspec.yaml index 2b162fe..3942a1b 100644 --- a/finlog_app/app/pubspec.yaml +++ b/finlog_app/app/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: cupertino_icons: ^1.0.8 go_router: ^16.2.2 animations: ^2.0.11 + provider: ^6.1.5+1 dev_dependencies: flutter_test: