feat(04-01): Android config, NotificationService, DAO queries, timezone init, ARB strings

- Add flutter_local_notifications ^21.0.0, timezone ^0.11.0, flutter_timezone ^1.0.8 to pubspec.yaml
- Set compileSdk=35, enable core library desugaring in build.gradle.kts
- Add POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED permissions and boot receivers to AndroidManifest.xml
- Create NotificationService singleton with initialize, requestPermission, scheduleDailyNotification, cancelAll
- Add getOverdueAndTodayTaskCount and getOverdueTaskCount one-shot queries to DailyPlanDao
- Initialize timezone and NotificationService in main.dart before runApp
- Add 7 notification-related ARB strings to app_de.arb
- All 72 existing tests pass
This commit is contained in:
2026-03-16 14:52:29 +01:00
parent abc56f032f
commit 878767138c
7 changed files with 155 additions and 3 deletions

View File

@@ -7,10 +7,11 @@ plugins {
android { android {
namespace = "com.jlmak.household_keeper" namespace = "com.jlmak.household_keeper"
compileSdk = flutter.compileSdkVersion compileSdk = 35
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
@@ -42,3 +43,7 @@ android {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

View File

@@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application <application
android:label="household_keeper" android:label="household_keeper"
android:name="${applicationName}" android:name="${applicationName}"
@@ -30,6 +33,19 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<receiver
android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver
android:exported="true"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -0,0 +1,80 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show TimeOfDay;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final _plugin = FlutterLocalNotificationsPlugin();
Future<void> initialize() async {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const settings = InitializationSettings(android: android);
await _plugin.initialize(
settings,
onDidReceiveNotificationResponse: _onTap,
);
}
Future<bool> requestPermission() async {
final android = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
return await android?.requestNotificationsPermission() ?? false;
}
Future<void> scheduleDailyNotification({
required TimeOfDay time,
required String title,
required String body,
}) async {
await _plugin.cancelAll();
final scheduledDate = _nextInstanceOf(time);
const details = NotificationDetails(
android: AndroidNotificationDetails(
'daily_summary',
'Tägliche Zusammenfassung',
channelDescription: 'Tägliche Aufgaben-Erinnerung',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
);
await _plugin.zonedSchedule(
0,
title: title,
body: body,
scheduledDate: scheduledDate,
details,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time,
);
}
Future<void> cancelAll() => _plugin.cancelAll();
/// Computes the next occurrence of [time] as a [tz.TZDateTime].
/// Returns today if [time] is still in the future, tomorrow otherwise.
@visibleForTesting
tz.TZDateTime nextInstanceOf(TimeOfDay time) {
final now = tz.TZDateTime.now(tz.local);
var scheduled = tz.TZDateTime(
tz.local,
now.year,
now.month,
now.day,
time.hour,
time.minute,
);
if (scheduled.isBefore(now)) {
scheduled = scheduled.add(const Duration(days: 1));
}
return scheduled;
}
static void _onTap(NotificationResponse response) {
// Navigation to Home tab — wired in Plan 02 via global navigator key
}
}

View File

@@ -32,6 +32,28 @@ class DailyPlanDao extends DatabaseAccessor<AppDatabase>
}); });
} }
/// One-shot count of overdue + today tasks (for notification body).
Future<int> getOverdueAndTodayTaskCount({DateTime? today}) async {
final now = today ?? DateTime.now();
final endOfToday = DateTime(now.year, now.month, now.day + 1);
final result = await (selectOnly(tasks)
..addColumns([tasks.id.count()])
..where(tasks.nextDueDate.isSmallerThanValue(endOfToday)))
.getSingle();
return result.read(tasks.id.count()) ?? 0;
}
/// One-shot count of overdue tasks only (for notification body split).
Future<int> getOverdueTaskCount({DateTime? today}) async {
final now = today ?? DateTime.now();
final startOfToday = DateTime(now.year, now.month, now.day);
final result = await (selectOnly(tasks)
..addColumns([tasks.id.count()])
..where(tasks.nextDueDate.isSmallerThanValue(startOfToday)))
.getSingle();
return result.read(tasks.id.count()) ?? 0;
}
/// Count task completions recorded today. /// Count task completions recorded today.
/// Uses customSelect with readsFrom for proper stream invalidation. /// Uses customSelect with readsFrom for proper stream invalidation.
Stream<int> watchCompletionsToday({DateTime? today}) { Stream<int> watchCompletionsToday({DateTime? today}) {

View File

@@ -88,5 +88,23 @@
"dailyPlanAllClearTitle": "Alles erledigt! \ud83c\udf1f", "dailyPlanAllClearTitle": "Alles erledigt! \ud83c\udf1f",
"dailyPlanAllClearMessage": "Keine Aufgaben f\u00fcr heute. Genie\u00dfe den Moment!", "dailyPlanAllClearMessage": "Keine Aufgaben f\u00fcr heute. Genie\u00dfe den Moment!",
"dailyPlanNoOverdue": "Keine \u00fcberf\u00e4lligen Aufgaben", "dailyPlanNoOverdue": "Keine \u00fcberf\u00e4lligen Aufgaben",
"dailyPlanNoTasks": "Noch keine Aufgaben angelegt" "dailyPlanNoTasks": "Noch keine Aufgaben angelegt",
"settingsSectionNotifications": "Benachrichtigungen",
"notificationsEnabledLabel": "Tägliche Erinnerung",
"notificationsTimeLabel": "Uhrzeit",
"notificationsPermissionDeniedHint": "Benachrichtigungen sind in den Systemeinstellungen deaktiviert. Tippe hier, um sie zu aktivieren.",
"notificationTitle": "Dein Tagesplan",
"notificationBody": "{count} Aufgaben fällig",
"@notificationBody": {
"placeholders": {
"count": { "type": "int" }
}
},
"notificationBodyWithOverdue": "{count} Aufgaben fällig ({overdue} überfällig)",
"@notificationBodyWithOverdue": {
"placeholders": {
"count": { "type": "int" },
"overdue": { "type": "int" }
}
}
} }

View File

@@ -1,9 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
import 'package:household_keeper/app.dart'; import 'package:household_keeper/app.dart';
import 'package:household_keeper/core/notifications/notification_service.dart';
void main() { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
tz.initializeTimeZones();
final timeZoneName = await FlutterTimezone.getLocalTimezone();
tz.setLocalLocation(tz.getLocation(timeZoneName));
await NotificationService().initialize();
runApp(const ProviderScope(child: App())); runApp(const ProviderScope(child: App()));
} }

View File

@@ -20,6 +20,9 @@ dependencies:
path_provider: ^2.1.5 path_provider: ^2.1.5
shared_preferences: ^2.5.4 shared_preferences: ^2.5.4
flutter_reorderable_grid_view: ^5.6.0 flutter_reorderable_grid_view: ^5.6.0
flutter_local_notifications: ^21.0.0
timezone: ^0.11.0
flutter_timezone: ^1.0.8
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: