diff --git a/lib/blocs/settings/habits/habit_settings_cubit.dart b/lib/blocs/settings/habits/habit_settings_cubit.dart index 440232446..24131c512 100644 --- a/lib/blocs/settings/habits/habit_settings_cubit.dart +++ b/lib/blocs/settings/habits/habit_settings_cubit.dart @@ -9,6 +9,7 @@ import 'package:lotti/classes/tag_type_definitions.dart'; import 'package:lotti/get_it.dart'; import 'package:lotti/logic/habits/autocomplete_update.dart'; import 'package:lotti/logic/persistence_logic.dart'; +import 'package:lotti/services/notification_service.dart'; import 'package:lotti/services/tags_service.dart'; import 'package:lotti/widgets/settings/habits/habit_autocomplete_widget.dart'; @@ -89,6 +90,27 @@ class HabitSettingsCubit extends Cubit { emitState(); } + void setAlertAtTime(DateTime? alertAtTime) { + _dirty = true; + _habitDefinition = _habitDefinition.copyWith( + habitSchedule: HabitSchedule.daily( + requiredCompletions: 1, + alertAtTime: alertAtTime, + ), + ); + emitState(); + } + + void clearAlertAtTime() { + _dirty = true; + _habitDefinition = _habitDefinition.copyWith( + habitSchedule: const HabitSchedule.daily( + requiredCompletions: 1, + ), + ); + emitState(); + } + Future onSavePressed() async { state.formKey.currentState!.save(); if (state.formKey.currentState!.validate()) { @@ -111,6 +133,8 @@ class HabitSettingsCubit extends Cubit { _dirty = false; emitState(); + await getIt().scheduleHabitNotification(dataType); + _maybePop(); } } diff --git a/lib/classes/entity_definitions.dart b/lib/classes/entity_definitions.dart index 5e60dbb10..299fe5070 100644 --- a/lib/classes/entity_definitions.dart +++ b/lib/classes/entity_definitions.dart @@ -13,6 +13,7 @@ class HabitSchedule with _$HabitSchedule { const factory HabitSchedule.daily({ required int requiredCompletions, DateTime? showFrom, + DateTime? alertAtTime, }) = DailyHabitSchedule; const factory HabitSchedule.weekly({ diff --git a/lib/classes/entity_definitions.freezed.dart b/lib/classes/entity_definitions.freezed.dart index 6461e6777..00910600c 100644 --- a/lib/classes/entity_definitions.freezed.dart +++ b/lib/classes/entity_definitions.freezed.dart @@ -34,7 +34,8 @@ mixin _$HabitSchedule { int get requiredCompletions => throw _privateConstructorUsedError; @optionalTypeArgs TResult when({ - required TResult Function(int requiredCompletions, DateTime? showFrom) + required TResult Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime) daily, required TResult Function(int requiredCompletions) weekly, required TResult Function(int requiredCompletions) monthly, @@ -42,14 +43,18 @@ mixin _$HabitSchedule { throw _privateConstructorUsedError; @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(int requiredCompletions, DateTime? showFrom)? daily, + TResult? Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime)? + daily, TResult? Function(int requiredCompletions)? weekly, TResult? Function(int requiredCompletions)? monthly, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeWhen({ - TResult Function(int requiredCompletions, DateTime? showFrom)? daily, + TResult Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime)? + daily, TResult Function(int requiredCompletions)? weekly, TResult Function(int requiredCompletions)? monthly, required TResult orElse(), @@ -131,7 +136,8 @@ abstract class _$$DailyHabitScheduleImplCopyWith<$Res> __$$DailyHabitScheduleImplCopyWithImpl<$Res>; @override @useResult - $Res call({int requiredCompletions, DateTime? showFrom}); + $Res call( + {int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime}); } /// @nodoc @@ -149,6 +155,7 @@ class __$$DailyHabitScheduleImplCopyWithImpl<$Res> $Res call({ Object? requiredCompletions = null, Object? showFrom = freezed, + Object? alertAtTime = freezed, }) { return _then(_$DailyHabitScheduleImpl( requiredCompletions: null == requiredCompletions @@ -159,6 +166,10 @@ class __$$DailyHabitScheduleImplCopyWithImpl<$Res> ? _value.showFrom : showFrom // ignore: cast_nullable_to_non_nullable as DateTime?, + alertAtTime: freezed == alertAtTime + ? _value.alertAtTime + : alertAtTime // ignore: cast_nullable_to_non_nullable + as DateTime?, )); } } @@ -167,7 +178,10 @@ class __$$DailyHabitScheduleImplCopyWithImpl<$Res> @JsonSerializable() class _$DailyHabitScheduleImpl implements DailyHabitSchedule { const _$DailyHabitScheduleImpl( - {required this.requiredCompletions, this.showFrom, final String? $type}) + {required this.requiredCompletions, + this.showFrom, + this.alertAtTime, + final String? $type}) : $type = $type ?? 'daily'; factory _$DailyHabitScheduleImpl.fromJson(Map json) => @@ -177,13 +191,15 @@ class _$DailyHabitScheduleImpl implements DailyHabitSchedule { final int requiredCompletions; @override final DateTime? showFrom; + @override + final DateTime? alertAtTime; @JsonKey(name: 'runtimeType') final String $type; @override String toString() { - return 'HabitSchedule.daily(requiredCompletions: $requiredCompletions, showFrom: $showFrom)'; + return 'HabitSchedule.daily(requiredCompletions: $requiredCompletions, showFrom: $showFrom, alertAtTime: $alertAtTime)'; } @override @@ -194,12 +210,15 @@ class _$DailyHabitScheduleImpl implements DailyHabitSchedule { (identical(other.requiredCompletions, requiredCompletions) || other.requiredCompletions == requiredCompletions) && (identical(other.showFrom, showFrom) || - other.showFrom == showFrom)); + other.showFrom == showFrom) && + (identical(other.alertAtTime, alertAtTime) || + other.alertAtTime == alertAtTime)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, requiredCompletions, showFrom); + int get hashCode => + Object.hash(runtimeType, requiredCompletions, showFrom, alertAtTime); /// Create a copy of HabitSchedule /// with the given fields replaced by the non-null parameter values. @@ -213,34 +232,39 @@ class _$DailyHabitScheduleImpl implements DailyHabitSchedule { @override @optionalTypeArgs TResult when({ - required TResult Function(int requiredCompletions, DateTime? showFrom) + required TResult Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime) daily, required TResult Function(int requiredCompletions) weekly, required TResult Function(int requiredCompletions) monthly, }) { - return daily(requiredCompletions, showFrom); + return daily(requiredCompletions, showFrom, alertAtTime); } @override @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(int requiredCompletions, DateTime? showFrom)? daily, + TResult? Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime)? + daily, TResult? Function(int requiredCompletions)? weekly, TResult? Function(int requiredCompletions)? monthly, }) { - return daily?.call(requiredCompletions, showFrom); + return daily?.call(requiredCompletions, showFrom, alertAtTime); } @override @optionalTypeArgs TResult maybeWhen({ - TResult Function(int requiredCompletions, DateTime? showFrom)? daily, + TResult Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime)? + daily, TResult Function(int requiredCompletions)? weekly, TResult Function(int requiredCompletions)? monthly, required TResult orElse(), }) { if (daily != null) { - return daily(requiredCompletions, showFrom); + return daily(requiredCompletions, showFrom, alertAtTime); } return orElse(); } @@ -290,7 +314,8 @@ class _$DailyHabitScheduleImpl implements DailyHabitSchedule { abstract class DailyHabitSchedule implements HabitSchedule { const factory DailyHabitSchedule( {required final int requiredCompletions, - final DateTime? showFrom}) = _$DailyHabitScheduleImpl; + final DateTime? showFrom, + final DateTime? alertAtTime}) = _$DailyHabitScheduleImpl; factory DailyHabitSchedule.fromJson(Map json) = _$DailyHabitScheduleImpl.fromJson; @@ -298,6 +323,7 @@ abstract class DailyHabitSchedule implements HabitSchedule { @override int get requiredCompletions; DateTime? get showFrom; + DateTime? get alertAtTime; /// Create a copy of HabitSchedule /// with the given fields replaced by the non-null parameter values. @@ -388,7 +414,8 @@ class _$WeeklyHabitScheduleImpl implements WeeklyHabitSchedule { @override @optionalTypeArgs TResult when({ - required TResult Function(int requiredCompletions, DateTime? showFrom) + required TResult Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime) daily, required TResult Function(int requiredCompletions) weekly, required TResult Function(int requiredCompletions) monthly, @@ -399,7 +426,9 @@ class _$WeeklyHabitScheduleImpl implements WeeklyHabitSchedule { @override @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(int requiredCompletions, DateTime? showFrom)? daily, + TResult? Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime)? + daily, TResult? Function(int requiredCompletions)? weekly, TResult? Function(int requiredCompletions)? monthly, }) { @@ -409,7 +438,9 @@ class _$WeeklyHabitScheduleImpl implements WeeklyHabitSchedule { @override @optionalTypeArgs TResult maybeWhen({ - TResult Function(int requiredCompletions, DateTime? showFrom)? daily, + TResult Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime)? + daily, TResult Function(int requiredCompletions)? weekly, TResult Function(int requiredCompletions)? monthly, required TResult orElse(), @@ -562,7 +593,8 @@ class _$MonthlyHabitScheduleImpl implements MonthlyHabitSchedule { @override @optionalTypeArgs TResult when({ - required TResult Function(int requiredCompletions, DateTime? showFrom) + required TResult Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime) daily, required TResult Function(int requiredCompletions) weekly, required TResult Function(int requiredCompletions) monthly, @@ -573,7 +605,9 @@ class _$MonthlyHabitScheduleImpl implements MonthlyHabitSchedule { @override @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(int requiredCompletions, DateTime? showFrom)? daily, + TResult? Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime)? + daily, TResult? Function(int requiredCompletions)? weekly, TResult? Function(int requiredCompletions)? monthly, }) { @@ -583,7 +617,9 @@ class _$MonthlyHabitScheduleImpl implements MonthlyHabitSchedule { @override @optionalTypeArgs TResult maybeWhen({ - TResult Function(int requiredCompletions, DateTime? showFrom)? daily, + TResult Function( + int requiredCompletions, DateTime? showFrom, DateTime? alertAtTime)? + daily, TResult Function(int requiredCompletions)? weekly, TResult Function(int requiredCompletions)? monthly, required TResult orElse(), diff --git a/lib/classes/entity_definitions.g.dart b/lib/classes/entity_definitions.g.dart index 527d4cdd2..bda0d87ba 100644 --- a/lib/classes/entity_definitions.g.dart +++ b/lib/classes/entity_definitions.g.dart @@ -13,6 +13,9 @@ _$DailyHabitScheduleImpl _$$DailyHabitScheduleImplFromJson( showFrom: json['showFrom'] == null ? null : DateTime.parse(json['showFrom'] as String), + alertAtTime: json['alertAtTime'] == null + ? null + : DateTime.parse(json['alertAtTime'] as String), $type: json['runtimeType'] as String?, ); @@ -21,6 +24,7 @@ Map _$$DailyHabitScheduleImplToJson( { 'requiredCompletions': instance.requiredCompletions, 'showFrom': instance.showFrom?.toIso8601String(), + 'alertAtTime': instance.alertAtTime?.toIso8601String(), 'runtimeType': instance.$type, }; diff --git a/lib/database/journal_db/config_flags.dart b/lib/database/journal_db/config_flags.dart index 8c715ee71..9e6da2e33 100644 --- a/lib/database/journal_db/config_flags.dart +++ b/lib/database/journal_db/config_flags.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:lotti/database/database.dart'; import 'package:lotti/utils/consts.dart'; @@ -84,13 +82,11 @@ Future initConfigFlags( status: false, ), ); - if (Platform.isMacOS) { - await db.insertFlagIfNotExists( - const ConfigFlag( - name: enableNotificationsFlag, - description: 'Enable desktop notifications?', - status: false, - ), - ); - } + await db.insertFlagIfNotExists( + const ConfigFlag( + name: enableNotificationsFlag, + description: 'Enable notifications?', + status: false, + ), + ); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 53f2c46ee..ed4dbe49c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -13,7 +13,7 @@ "cancelButton": "Abbrechen", "categoryDeleteConfirm": "JA, KATEGORIE LÖSCHEN", "categoryDeleteQuestion": "Kategorie wirklich löschen?", - "configFlagEnableNotifications": "Desktop Notifications erlauben?", + "configFlagEnableNotifications": "Notifications erlauben?", "configFlagPrivate": "Private Einträge anzeigen?", "conflictsResolved": "gelöst", "conflictsUnresolved": "offen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c21eb9e82..c0a6922b2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -45,7 +45,7 @@ "completeHabitFailButton": "Fail", "completeHabitSkipButton": "Skip", "completeHabitSuccessButton": "Success", - "configFlagEnableNotifications": "Enable desktop notifications?", + "configFlagEnableNotifications": "Enable notifications?", "configFlagPrivate": "Show private entries?", "configInvalidCert": "Allow invalid SSL certificate?", "conflictsResolved": "resolved", @@ -109,6 +109,7 @@ "habitsFilterOpenNow": "due", "habitsFilterPendingLater": "later", "habitShowFromLabel": "Show from", + "habitShowAlertAtLabel": "Show alert at", "habitsLongerStreaksEmptyHeader": "No week-long streaks at the moment", "habitsLongerStreaksHeader": "Week-long streaks (or longer)", "habitsOpenHeader": "Due now", diff --git a/lib/logic/persistence_logic.dart b/lib/logic/persistence_logic.dart index fde4c98ac..b77d287ba 100644 --- a/lib/logic/persistence_logic.dart +++ b/lib/logic/persistence_logic.dart @@ -266,6 +266,13 @@ class PersistenceLogic { shouldAddGeolocation: shouldAddGeolocation, ); + if (habitDefinition != null) { + await getIt().scheduleHabitNotification( + habitDefinition, + daysToAdd: 1, + ); + } + return habitCompletionEntry; } catch (exception, stackTrace) { _loggingService.captureException( @@ -766,16 +773,6 @@ class PersistenceLogic { ); } - if (dashboard.reviewAt != null && dashboard.deletedAt == null) { - await getIt().scheduleNotification( - title: 'Time for a Dashboard Review!', - body: dashboard.name, - notifyAt: dashboard.reviewAt!, - notificationId: dashboard.id.hashCode, - deepLink: '/dashboards/${dashboard.id}', - ); - } - return linesAffected; } diff --git a/lib/pages/settings/habits/habit_details_page.dart b/lib/pages/settings/habits/habit_details_page.dart index 7da7b2031..d3b2289ae 100644 --- a/lib/pages/settings/habits/habit_details_page.dart +++ b/lib/pages/settings/habits/habit_details_page.dart @@ -33,6 +33,8 @@ class HabitDetailsPage extends StatelessWidget { final cubit = context.read(); final isDaily = item.habitSchedule is DailyHabitSchedule; final showFrom = item.habitSchedule.mapOrNull(daily: (d) => d.showFrom); + final alertAtTime = + item.habitSchedule.mapOrNull(daily: (d) => d.alertAtTime); return Scaffold( appBar: TitleAppBar( @@ -114,13 +116,22 @@ class HabitDetailsPage extends StatelessWidget { mode: CupertinoDatePickerMode.date, ), inputSpacer, - if (isDaily) + if (isDaily) ...[ DateTimeField( dateTime: showFrom, labelText: context.messages.habitShowFromLabel, setDateTime: cubit.setShowFrom, mode: CupertinoDatePickerMode.time, ), + inputSpacer, + DateTimeField( + dateTime: alertAtTime, + labelText: context.messages.habitShowAlertAtLabel, + setDateTime: cubit.setAlertAtTime, + clear: cubit.clearAlertAtTime, + mode: CupertinoDatePickerMode.time, + ), + ], ], ), ), diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 74b3ff70b..4e200ee0a 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,74 +1,59 @@ import 'dart:io'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:lotti/classes/entity_definitions.dart'; import 'package:lotti/database/database.dart'; import 'package:lotti/get_it.dart'; -import 'package:lotti/services/nav_service.dart'; +import 'package:lotti/utils/consts.dart'; +import 'package:lotti/utils/timezone.dart'; +import 'package:timezone/timezone.dart'; final JournalDb _db = getIt(); class NotificationService { NotificationService() { - // flutterLocalNotificationsPlugin.initialize( - // const InitializationSettings( - // macOS: MacOSInitializationSettings( - // requestSoundPermission: false, - // requestBadgePermission: false, - // requestAlertPermission: false, - // ), - // iOS: IOSInitializationSettings( - // requestSoundPermission: false, - // requestBadgePermission: false, - // requestAlertPermission: false, - // ), - // ), - // onSelectNotification: onSelectNotification, - // ); + flutterLocalNotificationsPlugin.initialize( + const InitializationSettings( + macOS: DarwinInitializationSettings( + requestSoundPermission: false, + ), + iOS: DarwinInitializationSettings( + requestSoundPermission: false, + requestBadgePermission: false, + requestAlertPermission: false, + ), + ), + ); } int badgeCount = 0; final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - Future onSelectNotification(String? payload) async { - if (payload != null) { - beamToNamed(payload); - } - - // final details = - // await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); - - // if (details?.payload != null) { - // beamToNamed('${details?.payload}'); - // } - } - Future _requestPermissions() async { if (Platform.isWindows || Platform.isLinux) { return; } - // await flutterLocalNotificationsPlugin - // .resolvePlatformSpecificImplementation< - // IOSFlutterLocalNotificationsPlugin>() - // ?.requestPermissions( - // alert: true, - // badge: true, - // sound: true, - // ); - // - // await flutterLocalNotificationsPlugin - // .resolvePlatformSpecificImplementation< - // MacOSFlutterLocalNotificationsPlugin>() - // ?.requestPermissions( - // alert: true, - // badge: true, - // sound: true, - // ); + await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions( + alert: true, + badge: true, + ); + + await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions( + alert: true, + badge: true, + ); } Future updateBadge() async { -// final notifyEnabled = await _db.getConfigFlag(enableNotificationsFlag); + final notifyEnabled = await _db.getConfigFlag(enableNotificationsFlag); if (Platform.isWindows || Platform.isLinux) { return; @@ -87,46 +72,77 @@ class NotificationService { await flutterLocalNotificationsPlugin.cancel(1); if (badgeCount == 0) { - // await flutterLocalNotificationsPlugin.show( - // 1, - // '', - // '', - // NotificationDetails( - // iOS: IOSNotificationDetails( - // presentAlert: false, - // presentBadge: true, - // badgeNumber: badgeCount, - // ), - // macOS: MacOSNotificationDetails( - // presentAlert: false, - // presentBadge: true, - // badgeNumber: badgeCount, - // ), - // ), - // ); + await flutterLocalNotificationsPlugin.show( + 1, + '', + '', + NotificationDetails( + iOS: DarwinNotificationDetails( + presentAlert: false, + presentBadge: true, + badgeNumber: badgeCount, + ), + macOS: DarwinNotificationDetails( + presentAlert: false, + presentBadge: true, + badgeNumber: badgeCount, + ), + ), + ); return; } else { - // final title = '$badgeCount task${badgeCount == 1 ? '' : 's'} in progress'; - // final body = badgeCount < 5 ? 'Nice' : "Let's get that number down"; - - // await flutterLocalNotificationsPlugin.show( - // 1, - // title, - // body, - // NotificationDetails( - // iOS: IOSNotificationDetails( - // presentAlert: false, - // presentBadge: true, - // badgeNumber: badgeCount, - // ), - // macOS: MacOSNotificationDetails( - // presentAlert: notifyEnabled, - // presentBadge: true, - // badgeNumber: badgeCount, - // ), - // ), - // ); + final title = '$badgeCount task${badgeCount == 1 ? '' : 's'} in progress'; + final body = badgeCount < 5 ? 'Nice' : "Let's get that number down"; + + await flutterLocalNotificationsPlugin.show( + 1, + title, + body, + NotificationDetails( + iOS: DarwinNotificationDetails( + presentAlert: false, + presentBadge: true, + badgeNumber: badgeCount, + ), + macOS: DarwinNotificationDetails( + presentAlert: notifyEnabled, + presentBadge: true, + badgeNumber: badgeCount, + ), + ), + ); + } + } + + Future scheduleHabitNotification( + HabitDefinition habitDefinition, { + int daysToAdd = 0, + }) async { + final alertAtTime = habitDefinition.habitSchedule.maybeMap( + daily: (d) => d.alertAtTime, + orElse: () => null, + ); + + if (alertAtTime != null) { + final notifyAt = DateTime.now() + .add( + Duration(days: daysToAdd), + ) + .copyWith( + hour: alertAtTime.hour, + minute: alertAtTime.minute, + second: alertAtTime.second, + ); + + await getIt().scheduleNotification( + title: habitDefinition.name, + body: habitDefinition.description, + showOnMobile: true, + showOnDesktop: false, + notifyAt: notifyAt, + notificationId: habitDefinition.id.hashCode, + ); } } @@ -135,49 +151,62 @@ class NotificationService { required String body, required DateTime notifyAt, required int notificationId, + required bool showOnMobile, + required bool showOnDesktop, + bool repeat = false, String? deepLink, }) async { - if (Platform.isWindows || Platform.isLinux) { + final notifyEnabled = await _db.getConfigFlag(enableNotificationsFlag); + + if (!notifyEnabled || Platform.isWindows || Platform.isLinux) { return; } - // TODO: bring back or remove - // await _requestPermissions(); - // await flutterLocalNotificationsPlugin.cancel(notificationId); - // // final now = DateTime.now(); - // final localTimezone = await getLocalTimezone(); - // final location = tz.getLocation(localTimezone); - // debugPrint('scheduleNotification $localTimezone $location $notifyAt'); - // final scheduledDate = tz.TZDateTime( - // location, - // now.year, - // now.month, - // now.day, - // notifyAt.hour, - // notifyAt.minute, - // ); - - // await flutterLocalNotificationsPlugin.zonedSchedule( - // notificationId, - // title, - // body, - // scheduledDate, - // const NotificationDetails( - // iOS: IOSNotificationDetails( - // presentAlert: true, - // presentSound: true, - // ), - // macOS: MacOSNotificationDetails( - // presentAlert: true, - // presentSound: true, - // ), - // ), - // uiLocalNotificationDateInterpretation: - // UILocalNotificationDateInterpretation.wallClockTime, - // androidAllowWhileIdle: true, - // matchDateTimeComponents: DateTimeComponents.time, - // payload: deepLink, - // ); + await _requestPermissions(); + await flutterLocalNotificationsPlugin.cancel(notificationId); + final now = DateTime.now(); + final localTimezone = await getLocalTimezone(); + final location = getLocation(localTimezone); + + final scheduledDate = TZDateTime( + location, + now.year, + now.month, + now.day, + notifyAt.hour, + notifyAt.minute, + notifyAt.second, + ); + + await flutterLocalNotificationsPlugin.zonedSchedule( + notificationId, + title, + body, + scheduledDate, + NotificationDetails( + iOS: showOnMobile + ? const DarwinNotificationDetails( + presentAlert: true, + presentSound: true, + presentBanner: true, + interruptionLevel: InterruptionLevel.timeSensitive, + ) + : null, + macOS: showOnDesktop + ? DarwinNotificationDetails( + presentAlert: true, + presentBanner: true, + subtitle: title, + interruptionLevel: InterruptionLevel.timeSensitive, + ) + : null, + ), + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.wallClockTime, + matchDateTimeComponents: repeat ? DateTimeComponents.time : null, + payload: deepLink, + androidScheduleMode: AndroidScheduleMode.exact, + ); } Future cancelNotification(int notificationId) async { @@ -209,21 +238,21 @@ class NotificationService { await _requestPermissions(); await flutterLocalNotificationsPlugin.cancel(notificationId); - // await flutterLocalNotificationsPlugin.show( - // notificationId, - // title, - // body, - // const NotificationDetails( - // iOS: IOSNotificationDetails( - // presentAlert: true, - // presentSound: true, - // ), - // macOS: MacOSNotificationDetails( - // presentAlert: true, - // presentSound: true, - // ), - // ), - // payload: deepLink, - // ); + await flutterLocalNotificationsPlugin.show( + notificationId, + title, + body, + const NotificationDetails( + iOS: DarwinNotificationDetails( + presentAlert: true, + presentSound: true, + ), + macOS: DarwinNotificationDetails( + presentAlert: true, + presentSound: true, + ), + ), + payload: deepLink, + ); } } diff --git a/lib/widgets/date_time/datetime_field.dart b/lib/widgets/date_time/datetime_field.dart index 41cdc79cd..bf38b32f3 100644 --- a/lib/widgets/date_time/datetime_field.dart +++ b/lib/widgets/date_time/datetime_field.dart @@ -9,6 +9,7 @@ class DateTimeField extends StatefulWidget { required this.dateTime, required this.labelText, required this.setDateTime, + this.clear, this.mode = CupertinoDatePickerMode.dateAndTime, super.key, }); @@ -16,6 +17,7 @@ class DateTimeField extends StatefulWidget { final DateTime? dateTime; final String labelText; final void Function(DateTime) setDateTime; + final void Function()? clear; final CupertinoDatePickerMode mode; @override @@ -38,6 +40,13 @@ class _DateTimeFieldState extends State { labelText: widget.labelText, style: style, themeData: Theme.of(context), + ).copyWith( + suffixIcon: widget.clear != null + ? IconButton( + onPressed: widget.clear, + icon: const Icon(Icons.clear), + ) + : null, ), style: style, readOnly: true, diff --git a/pubspec.yaml b/pubspec.yaml index b799dd726..ae19ffa0f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: lotti description: Achieve your goals and keep your data private with Lotti. publish_to: 'none' -version: 0.9.567+2871 +version: 0.9.567+2874 msix_config: display_name: LottiApp diff --git a/test/database/database_test.dart b/test/database/database_test.dart index 758e36a21..5b9eb5a57 100644 --- a/test/database/database_test.dart +++ b/test/database/database_test.dart @@ -77,7 +77,7 @@ final expectedFlags = { final expectedMacFlags = { const ConfigFlag( name: enableNotificationsFlag, - description: 'Enable desktop notifications?', + description: 'Enable notifications?', status: false, ), }; diff --git a/test/features/journal/ui/widgets/tags/tags_modal_widget_test.dart b/test/features/journal/ui/widgets/tags/tags_modal_widget_test.dart index 8327c973d..192864239 100644 --- a/test/features/journal/ui/widgets/tags/tags_modal_widget_test.dart +++ b/test/features/journal/ui/widgets/tags/tags_modal_widget_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lotti/classes/journal_entities.dart'; import 'package:lotti/classes/tag_type_definitions.dart'; import 'package:lotti/database/database.dart'; import 'package:lotti/database/logging_db.dart'; @@ -25,6 +26,8 @@ import '../../../../../widget_test_utils.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + registerFallbackValue(FakeMetadata()); + group('TagsModal Widget Tests -', () { final mockTagsService = MockTagsService(); final mockEditorStateService = MockEditorStateService(); @@ -45,6 +48,13 @@ void main() { ]), ); + when( + () => mockPersistenceLogic.updateMetadata(any()), + ).thenAnswer((_) async => testTextEntry.meta); + + when(() => mockTagsService.getFilteredStoryTagIds(any())) + .thenAnswer((_) => []); + when(mockTagsService.watchTags).thenAnswer( (_) => Stream>.fromIterable([ [ @@ -96,6 +106,10 @@ void main() { () => mockJournalDb.upsertTagEntity(any()), ).thenAnswer((invocation) async => 1); + when( + () => mockJournalDb.getLinkedEntities(any()), + ).thenAnswer((_) async => []); + when( () => mockVectorClockService.getNextVectorClock( previous: any(named: 'previous'), @@ -126,7 +140,7 @@ void main() { await tester.pumpWidget( makeTestableWidgetWithScaffold( TagsModal( - entryId: testTextEntryWithTags.meta.id, + entryId: testTextEntryWithTags.id, ), ), ); @@ -142,9 +156,13 @@ void main() { await tester.tap(copyIconFinder); await tester.pumpAndSettle(); + verify(() => mockTagsService.setClipboard(any())).called(1); + await tester.tap(pasteIconFinder); await tester.pumpAndSettle(); + verify(mockTagsService.getClipboard).called(1); + verify(() => mockPersistenceLogic.updateMetadata(any())).called(1); }); testWidgets('select existing tag', (tester) async { @@ -160,10 +178,14 @@ void main() { await tester.enterText(searchFieldFinder, 'some'); await tester.pumpAndSettle(); + verify(() => mockTagsService.getMatchingTags('some')).called(1); + final tagFinder = find.text(testTag1.tag); expect(tagFinder, findsOneWidget); await tester.tap(tagFinder); await tester.pumpAndSettle(); + + verify(() => mockPersistenceLogic.updateMetadata(any())).called(1); }); testWidgets('add new tag', (tester) async { @@ -204,6 +226,9 @@ void main() { await tester.enterText(searchFieldFinder, newTag.tag); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(const Duration(seconds: 5)); + + verify(() => mockJournalDb.upsertTagEntity(any())).called(1); + verify(() => mockPersistenceLogic.updateMetadata(any())).called(1); }); testWidgets('remove tag', (tester) async { @@ -229,6 +254,11 @@ void main() { expect(closeIconFinder, findsNWidgets(2)); await tester.tap(closeIconFinder.first); + await tester.pumpAndSettle(); + + verify(() => mockPersistenceLogic.updateMetadata(any())).called(1); + + expect(find.byIcon(Icons.close_rounded), findsNWidgets(2)); }); }); } diff --git a/test/logic/persistence_logic_test.dart b/test/logic/persistence_logic_test.dart index 2a8018c6b..4e9cc5f2d 100644 --- a/test/logic/persistence_logic_test.dart +++ b/test/logic/persistence_logic_test.dart @@ -39,6 +39,7 @@ void main() { final secureStorageMock = MockSecureStorage(); setFakeDocumentsPath(); registerFallbackValue(FakeJournalEntity()); + registerFallbackValue(FakeHabitDefinition()); final mockNotificationService = MockNotificationService(); final mockUpdateNotifications = MockUpdateNotifications(); @@ -78,7 +79,19 @@ void main() { (_) => Stream>.fromIterable([]), ); - when(() => mockFts5Db.insertText(any())).thenAnswer((_) async {}); + when( + () => mockFts5Db.insertText( + any(), + removePrevious: true, + ), + ).thenAnswer((_) async {}); + + when( + () => mockNotificationService.scheduleHabitNotification( + any(), + daysToAdd: any(named: 'daysToAdd'), + ), + ).thenAnswer((_) async {}); when( () => mockNotificationService.showNotification( @@ -137,11 +150,13 @@ void main() { testText, ); + final updated = textEntry.copyWith( + entryText: const EntryText(plainText: updatedTestText), + ); + // update entry with new plaintext await getIt().updateJournalEntity( - textEntry.copyWith( - entryText: const EntryText(plainText: updatedTestText), - ), + updated, textEntry.meta, ); @@ -153,6 +168,9 @@ void main() { updatedTestText, ); + verify(() => mockFts5Db.insertText(any(), removePrevious: true)) + .called(1); + // TODO: why is this failing suddenly? //verify(mockNotificationService.updateBadge).called(2); }, diff --git a/test/mocks/mocks.dart b/test/mocks/mocks.dart index a6f487214..405519d62 100644 --- a/test/mocks/mocks.dart +++ b/test/mocks/mocks.dart @@ -136,6 +136,8 @@ class MockNotificationService extends Mock implements NotificationService {} class FakeDashboardDefinition extends Fake implements DashboardDefinition {} +class FakeHabitDefinition extends Fake implements HabitDefinition {} + class FakeCategoryDefinition extends Fake implements CategoryDefinition {} class FakeTagEntity extends Fake implements TagEntity {} diff --git a/test/pages/settings/habits/habit_details_page_test.dart b/test/pages/settings/habits/habit_details_page_test.dart index 84d74042c..da226ffbb 100644 --- a/test/pages/settings/habits/habit_details_page_test.dart +++ b/test/pages/settings/habits/habit_details_page_test.dart @@ -10,6 +10,7 @@ import 'package:lotti/logic/persistence_logic.dart'; import 'package:lotti/pages/settings/habits/habit_create_page.dart'; import 'package:lotti/pages/settings/habits/habit_details_page.dart'; import 'package:lotti/services/entities_cache_service.dart'; +import 'package:lotti/services/notification_service.dart'; import 'package:lotti/services/tags_service.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:mocktail/mocktail.dart'; @@ -28,10 +29,12 @@ void main() { var mockJournalDb = MockJournalDb(); var mockPersistenceLogic = MockPersistenceLogic(); final mockEntitiesCacheService = MockEntitiesCacheService(); + final mockNotificationService = MockNotificationService(); group('HabitDetailsPage Widget Tests - ', () { setUpAll(() { registerFallbackValue(FakeDashboardDefinition()); + registerFallbackValue(FakeHabitDefinition()); }); setUp(() { @@ -47,6 +50,11 @@ void main() { (_) => [categoryMindfulness], ); + when(() => mockNotificationService.scheduleHabitNotification(any())) + .thenAnswer( + (_) => Future.value(), + ); + when(mockJournalDb.watchDashboards).thenAnswer( (_) => Stream>.fromIterable([ [testDashboardConfig], @@ -71,6 +79,7 @@ void main() { ..registerSingleton(mockJournalDb) ..registerSingleton(mockPersistenceLogic) ..registerSingleton(mockEntitiesCacheService) + ..registerSingleton(mockNotificationService) ..registerSingleton(mockTagsService); }); tearDown(getIt.reset);