Feature: Add Settings module with PanelNavigator, AppSettingsView, and related components for nested navigation + theming

This commit is contained in:
2025-09-25 21:21:01 +02:00
parent cfa5ceb393
commit bf5dc6b69c
7 changed files with 786 additions and 1 deletions

View File

@@ -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<AppSettingsViewModel>();
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<AppSettingsViewModel>();
final scheme = Theme.of(context).colorScheme;
// A few pleasant presets; extend as needed.
final presets = <Color>[
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<AppSettingsViewModel>();
final selected = vm.textSize;
Widget radio(AppTextSize size, String label, String sub) {
return RadioListTile<AppTextSize>(
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<AppSettingsViewModel>();
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<AppLanguage>(
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<AppSettingsViewModel>();
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'),
);
}
}

View File

@@ -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<void> load() async {
_isLoading = true;
notifyListeners();
// TODO: Replace with real backend call.
await Future<void>.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<void> save() async {
if (_isSaving) return;
_isSaving = true;
notifyListeners();
// TODO: Replace with real backend update.
await Future<void>.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<String, dynamic> toJson() => {
'backgroundColorSystem': _backgroundColorSystem?.value,
// null => system
'textSize': _textSize.name,
'language': _language.name,
};
void fromJson(Map<String, dynamic> 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();
}
}

View File

@@ -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'),
),
],
),
),
],
);
}
}