feat(04-02): wire notification settings UI, permission flow, scheduling, and tap navigation

- Convert SettingsScreen from ConsumerWidget to ConsumerStatefulWidget
- Add Benachrichtigungen section between Darstellung and Uber sections
- SwitchListTile with permission request on toggle ON (Android 13+)
- Toggle reverts to OFF on permission denial with SnackBar hint
- AnimatedSize progressive disclosure for time picker row when enabled
- _scheduleNotification() queries DailyPlanDao for task/overdue counts
- Skip notification scheduling when task count is 0
- Notification body includes overdue split when overdue > 0
- _onPickTime() shows Material 3 showTimePicker dialog then reschedules
- Wire router.go('/') in NotificationService._onTap for tap navigation
- Regenerate AppLocalizations with 7 new notification strings from Plan 01 ARB
This commit is contained in:
2026-03-16 15:06:00 +01:00
parent 903d80f63e
commit 0103ddebbb
4 changed files with 187 additions and 4 deletions

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart' show TimeOfDay;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:household_keeper/core/router/router.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
@@ -74,6 +76,6 @@ class NotificationService {
}
static void _onTap(NotificationResponse response) {
// Navigation to Home tab — wired in Plan 02 via global navigator key
router.go('/');
}
}

View File

@@ -1,17 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:household_keeper/core/notifications/notification_service.dart';
import 'package:household_keeper/core/notifications/notification_settings_notifier.dart';
import 'package:household_keeper/core/providers/database_provider.dart';
import 'package:household_keeper/core/theme/theme_provider.dart';
import 'package:household_keeper/features/home/data/daily_plan_dao.dart';
import 'package:household_keeper/l10n/app_localizations.dart';
class SettingsScreen extends ConsumerWidget {
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Future<void> _onNotificationToggle(bool value) async {
if (value) {
// Enabling: request permission first
final granted = await NotificationService().requestPermission();
if (!granted) {
// Permission denied — show SnackBar to guide user to system settings
if (!mounted) return;
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.notificationsPermissionDeniedHint),
),
);
// Toggle stays OFF — do not update state
return;
}
// Permission granted: enable and schedule
await ref
.read(notificationSettingsProvider.notifier)
.setEnabled(true);
await _scheduleNotification();
} else {
// Disabling: update state and cancel
await ref
.read(notificationSettingsProvider.notifier)
.setEnabled(false);
await NotificationService().cancelAll();
}
}
Future<void> _scheduleNotification() async {
final settings = ref.read(notificationSettingsProvider);
if (!settings.enabled) return;
final db = ref.read(appDatabaseProvider);
final dao = DailyPlanDao(db);
final total = await dao.getOverdueAndTodayTaskCount();
final overdue = await dao.getOverdueTaskCount();
if (total == 0) {
// No tasks today — skip notification
await NotificationService().cancelAll();
return;
}
if (!mounted) return;
final l10n = AppLocalizations.of(context);
final body = overdue > 0
? l10n.notificationBodyWithOverdue(total, overdue)
: l10n.notificationBody(total);
final title = l10n.notificationTitle;
await NotificationService().scheduleDailyNotification(
time: settings.time,
title: title,
body: body,
);
}
Future<void> _onPickTime() async {
final settings = ref.read(notificationSettingsProvider);
final picked = await showTimePicker(
context: context,
initialTime: settings.time,
initialEntryMode: TimePickerEntryMode.dial,
);
if (picked != null) {
await ref
.read(notificationSettingsProvider.notifier)
.setTime(picked);
await _scheduleNotification();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final currentThemeMode = ref.watch(themeProvider);
final notificationSettings = ref.watch(notificationSettingsProvider);
return ListView(
children: [
@@ -59,7 +143,36 @@ class SettingsScreen extends ConsumerWidget {
const Divider(indent: 16, endIndent: 16, height: 32),
// Section 2: About (Ueber)
// Section 2: Notifications (Benachrichtigungen)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
l10n.settingsSectionNotifications,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.primary,
),
),
),
SwitchListTile(
title: Text(l10n.notificationsEnabledLabel),
value: notificationSettings.enabled,
onChanged: _onNotificationToggle,
),
// Progressive disclosure: time picker only when enabled
AnimatedSize(
duration: const Duration(milliseconds: 200),
child: notificationSettings.enabled
? ListTile(
title: Text(l10n.notificationsTimeLabel),
trailing: Text(notificationSettings.time.format(context)),
onTap: _onPickTime,
)
: const SizedBox.shrink(),
),
const Divider(indent: 16, endIndent: 16, height: 32),
// Section 3: About (Ueber)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(

View File

@@ -471,6 +471,48 @@ abstract class AppLocalizations {
/// In de, this message translates to:
/// **'Noch keine Aufgaben angelegt'**
String get dailyPlanNoTasks;
/// No description provided for @settingsSectionNotifications.
///
/// In de, this message translates to:
/// **'Benachrichtigungen'**
String get settingsSectionNotifications;
/// No description provided for @notificationsEnabledLabel.
///
/// In de, this message translates to:
/// **'Tägliche Erinnerung'**
String get notificationsEnabledLabel;
/// No description provided for @notificationsTimeLabel.
///
/// In de, this message translates to:
/// **'Uhrzeit'**
String get notificationsTimeLabel;
/// No description provided for @notificationsPermissionDeniedHint.
///
/// In de, this message translates to:
/// **'Benachrichtigungen sind in den Systemeinstellungen deaktiviert. Tippe hier, um sie zu aktivieren.'**
String get notificationsPermissionDeniedHint;
/// No description provided for @notificationTitle.
///
/// In de, this message translates to:
/// **'Dein Tagesplan'**
String get notificationTitle;
/// No description provided for @notificationBody.
///
/// In de, this message translates to:
/// **'{count} Aufgaben fällig'**
String notificationBody(int count);
/// No description provided for @notificationBodyWithOverdue.
///
/// In de, this message translates to:
/// **'{count} Aufgaben fällig ({overdue} überfällig)'**
String notificationBodyWithOverdue(int count, int overdue);
}
class _AppLocalizationsDelegate

View File

@@ -210,4 +210,30 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dailyPlanNoTasks => 'Noch keine Aufgaben angelegt';
@override
String get settingsSectionNotifications => 'Benachrichtigungen';
@override
String get notificationsEnabledLabel => 'Tägliche Erinnerung';
@override
String get notificationsTimeLabel => 'Uhrzeit';
@override
String get notificationsPermissionDeniedHint =>
'Benachrichtigungen sind in den Systemeinstellungen deaktiviert. Tippe hier, um sie zu aktivieren.';
@override
String get notificationTitle => 'Dein Tagesplan';
@override
String notificationBody(int count) {
return '$count Aufgaben fällig';
}
@override
String notificationBodyWithOverdue(int count, int overdue) {
return '$count Aufgaben fällig ($overdue überfällig)';
}
}