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:
@@ -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('/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user