mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 15:23:16 +02:00
M3 – GitHub OAuth device flow: replace PAT-paste with a guided "Connect
GitHub" button that runs the device-code flow; tapping with no client id
now opens a setup dialog (instructions + inline paste field) rather than
a buried inline hint. Bakes in the app's own OAuth App client id so fresh
installs work with zero manual config. Auto-syncs immediately after
connect. Verified end-to-end on the real phone: OAuth flow → token saved
→ PC's 48-entry log merged in (confirmed via food-bank vs manual source
labels in History).
M4 – Background meal-slot notifications: WorkManager periodic task (15 min
floor) checks for overdue slots and posts/cancels notifications via
flutter_local_notifications. New permissions: POST_NOTIFICATIONS,
WAKE_LOCK, RECEIVE_BOOT_COMPLETED, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
INTERNET (was missing — latent sync bug). "Disable battery optimization"
button in Settings. Verified on real phone: WorkManager registered, forced
run posted a real notification ("Meal not logged / You haven't logged your
16:00 meal yet."), isolated to background path (only caller is the
WorkManager dispatcher, not any foreground lifecycle hook).
AGP9 release crash fix: AGP 9 defaults isMinifyEnabled/isShrinkResources
to true for release even with no proguard config; R8 stripped
WorkDatabase_Impl's reflection-only constructor, crashing every launch
with NoSuchMethodException. Explicitly disabled both flags in
build.gradle.kts. Verified via dexdump (constructor present) and on-device
launch (no crash). Proper R8 keep rules are the long-term fix; tracked.
177 tests, flutter analyze clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWPUBzE24Ls9i9GMRwXnnn
106 lines
3.4 KiB
Dart
106 lines
3.4 KiB
Dart
import 'package:diet_guard_app/services/notification_service.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import '../fake_notifications.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
tearDown(NotificationService.resetForTesting);
|
|
|
|
group('on Android', () {
|
|
test('init constructs the real plugin singleton on first use', () async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(); // no _instance yet
|
|
|
|
await NotificationService.init();
|
|
|
|
expect(log.where((c) => c.method == 'initialize'), hasLength(1));
|
|
});
|
|
|
|
test('init calls the platform initialize method, idempotently', () async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
plugin: FlutterLocalNotificationsPlugin(),
|
|
);
|
|
|
|
await NotificationService.init();
|
|
await NotificationService.init(); // second call must be a no-op
|
|
|
|
expect(log.where((c) => c.method == 'initialize'), hasLength(1));
|
|
});
|
|
|
|
test('requestPermission delegates to the Android implementation', () async {
|
|
installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
plugin: FlutterLocalNotificationsPlugin(),
|
|
);
|
|
await NotificationService.init();
|
|
|
|
expect(await NotificationService.instance.requestPermission(), isTrue);
|
|
});
|
|
|
|
test('syncToSlots shows due slots and cancels the rest', () async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
plugin: FlutterLocalNotificationsPlugin(),
|
|
);
|
|
await NotificationService.init();
|
|
log.clear();
|
|
|
|
await NotificationService.instance.syncToSlots([12, 20]);
|
|
|
|
final shown = log
|
|
.where((c) => c.method == 'show')
|
|
.map((c) => (c.arguments as Map)['id'])
|
|
.toSet();
|
|
final cancelled = log
|
|
.where((c) => c.method == 'cancel')
|
|
.map((c) => (c.arguments as Map)['id'])
|
|
.toSet();
|
|
expect(shown, {12, 20});
|
|
expect(cancelled, {8, 16});
|
|
});
|
|
|
|
test('syncToSlots with no due slots cancels every known slot', () async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
plugin: FlutterLocalNotificationsPlugin(),
|
|
);
|
|
await NotificationService.init();
|
|
log.clear();
|
|
|
|
await NotificationService.instance.syncToSlots(const []);
|
|
|
|
expect(log.where((c) => c.method == 'show'), isEmpty);
|
|
expect(log.where((c) => c.method == 'cancel'), hasLength(4));
|
|
});
|
|
|
|
test(
|
|
'syncToSlots cancels a slot whose meal was logged after it fired',
|
|
() async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
plugin: FlutterLocalNotificationsPlugin(),
|
|
);
|
|
await NotificationService.init();
|
|
|
|
await NotificationService.instance.syncToSlots([12]);
|
|
log.clear();
|
|
await NotificationService.instance.syncToSlots(const []); // logged
|
|
|
|
expect(
|
|
log
|
|
.where((c) => c.method == 'cancel')
|
|
.map((c) => (c.arguments as Map)['id']),
|
|
contains(12),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
test('instance throws before init has ever been called', () {
|
|
expect(() => NotificationService.instance, throwsA(anything));
|
|
});
|
|
}
|