diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 56b2e83e..fbfa3eae 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -24,7 +24,7 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: "3.19.3" + flutter-version: "3.22.2" channel: 'stable' cache: true diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 2f5a3d9b..6116d902 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -25,7 +25,7 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: "3.19.3" + flutter-version: "3.22.2" channel: 'stable' cache: false diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b4a9535b..673baac5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,16 +5,19 @@ android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> - - + + + + + + - - - + + + Display > Reliability > Show Foreground Notification +* Added current lap card for stopwatch. Now you can view the current lap time in real time. +* Added option to show next alarm in filters. Go to Settings > Alarms > Filters > Show Next Alarm. +* Changed timer and stopwatch notification so time appears in title +* Updated translations + +🐛 Fixes + +* Fixed range schedule automatically getting disabled +* Fixed timer dismiss actions being mapped incorrectly +* Fixed timer add length not working correctly after timer rings + diff --git a/fastlane/metadata/android/en-US/changelogs/252.txt b/fastlane/metadata/android/en-US/changelogs/252.txt new file mode 100644 index 00000000..ecf6e5f3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/252.txt @@ -0,0 +1,14 @@ +✨ Enhancements + +* Added option to turn on foreground notification to keep app alive. Go to General > Display > Reliability > Show Foreground Notification +* Added current lap card for stopwatch. Now you can view the current lap time in real time. +* Added option to show next alarm in filters. Go to Settings > Alarms > Filters > Show Next Alarm. +* Changed timer and stopwatch notification so time appears in title +* Updated translations + +🐛 Fixes + +* Fixed range schedule automatically getting disabled +* Fixed timer dismiss actions being mapped incorrectly +* Fixed timer add length not working correctly after timer rings + diff --git a/fastlane/metadata/android/en-US/changelogs/253.txt b/fastlane/metadata/android/en-US/changelogs/253.txt new file mode 100644 index 00000000..ecf6e5f3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/253.txt @@ -0,0 +1,14 @@ +✨ Enhancements + +* Added option to turn on foreground notification to keep app alive. Go to General > Display > Reliability > Show Foreground Notification +* Added current lap card for stopwatch. Now you can view the current lap time in real time. +* Added option to show next alarm in filters. Go to Settings > Alarms > Filters > Show Next Alarm. +* Changed timer and stopwatch notification so time appears in title +* Updated translations + +🐛 Fixes + +* Fixed range schedule automatically getting disabled +* Fixed timer dismiss actions being mapped incorrectly +* Fixed timer add length not working correctly after timer rings + diff --git a/lib/alarm/data/alarm_settings_schema.dart b/lib/alarm/data/alarm_settings_schema.dart index 46928399..d690d7d2 100644 --- a/lib/alarm/data/alarm_settings_schema.dart +++ b/lib/alarm/data/alarm_settings_schema.dart @@ -25,6 +25,7 @@ import 'package:clock_app/settings/types/setting.dart'; import 'package:clock_app/settings/types/setting_enable_condition.dart'; import 'package:clock_app/settings/types/setting_group.dart'; import 'package:clock_app/timer/types/time_duration.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -209,6 +210,15 @@ SettingGroup alarmSettingsSchema = SettingGroup( 100, 100, unit: "%"), + SliderSetting( + "task_volume", + (context) => AppLocalizations.of(context)!.volumeWhileTasks, + 0, + 100, + 50, + unit: "%", + getDescription: (context) => "Percentage of base volume", + ), SwitchSetting( "Rising Volume", (context) => AppLocalizations.of(context)!.risingVolumeSetting, @@ -294,7 +304,9 @@ SettingGroup alarmSettingsSchema = SettingGroup( ListSetting( "Tasks", (context) => AppLocalizations.of(context)!.tasksSetting, - [], + kDebugMode + ? [AlarmTask(AlarmTaskType.math), AlarmTask(AlarmTaskType.sequence)] + : [], alarmTaskSchemasMap.keys.map((key) => AlarmTask(key)).toList(), addCardBuilder: (item) => AlarmTaskCard(task: item, isAddCard: true), cardBuilder: (item, [onDelete, onDuplicate]) => AlarmTaskCard( diff --git a/lib/alarm/logic/alarm_isolate.dart b/lib/alarm/logic/alarm_isolate.dart index 7823a6ed..c9e1699a 100644 --- a/lib/alarm/logic/alarm_isolate.dart +++ b/lib/alarm/logic/alarm_isolate.dart @@ -4,8 +4,8 @@ import 'dart:ui'; import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/notification_type.dart'; import 'package:clock_app/common/utils/list_storage.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:clock_app/system/logic/initialize_isolate.dart'; -import 'package:clock_app/timer/types/time_duration.dart'; import 'package:clock_app/timer/types/timer.dart'; import 'package:flutter/foundation.dart'; import 'package:clock_app/alarm/logic/schedule_alarm.dart'; @@ -21,18 +21,23 @@ import 'package:clock_app/timer/utils/timer_id.dart'; const String stopAlarmPortName = "stopAlarmPort"; const String updatePortName = "updatePort"; +const String setAlarmVolumePortName = "setAlarmVolumePort"; @pragma('vm:entry-point') void triggerScheduledNotification(int scheduleId, Json params) async { - debugPrint("Alarm triggered: $scheduleId"); + FlutterError.onError = (FlutterErrorDetails details) { + logger.f(details.exception.toString()); + }; + + logger.i("Alarm isolate triggered $scheduleId"); // print("Alarm Trigger Isolate: ${Service.getIsolateID(Isolate.current)}"); if (params == null) { - debugPrint("Params was null when triggering alarm"); + logger.e("Params was null when triggering alarm"); return; } if (params['type'] == null) { - debugPrint("Params Type was null when triggering alarm"); + logger.e("Params Type was null when triggering alarm"); return; } @@ -73,10 +78,9 @@ void stopScheduledNotification(List message) { } void triggerAlarm(int scheduleId, Json params) async { + logger.i("Alarm triggered $scheduleId"); if (params == null) { - if (kDebugMode) { - print("Params was null when triggering alarm"); - } + logger.e("Params was null when triggering alarm"); return; } @@ -100,6 +104,7 @@ void triggerAlarm(int scheduleId, Json params) async { now.millisecondsSinceEpoch > alarm.currentScheduleDateTime!.millisecondsSinceEpoch + 1000 * 60 * 60) { + logger.i("Skipping alarm $scheduleId"); return; } @@ -118,6 +123,14 @@ void triggerAlarm(int scheduleId, Json params) async { RingtonePlayer.playAlarm(alarm); RingingManager.ringAlarm(scheduleId); + ReceivePort receivePort = ReceivePort(); + IsolateNameServer.removePortNameMapping(setAlarmVolumePortName); + IsolateNameServer.registerPortWithName( + receivePort.sendPort, setAlarmVolumePortName); + receivePort.listen((message) { + setVolume(message[0]); + }); + String timeFormatString = await loadTextFile("time_format_string"); String title = alarm.label.isEmpty ? "Alarm Ringing..." : alarm.label; @@ -137,10 +150,11 @@ void triggerAlarm(int scheduleId, Json params) async { } void setVolume(double volume) { - RingtonePlayer.setVolume(volume); + RingtonePlayer.setVolume(volume / 100); } void stopAlarm(int scheduleId, AlarmStopAction action) async { + logger.i("Stopping alarm $scheduleId with action: ${action.name}"); if (action == AlarmStopAction.snooze) { await updateAlarmById(scheduleId, (alarm) async => await alarm.snooze()); // await createSnoozeNotification(scheduleId); @@ -158,6 +172,7 @@ void stopAlarm(int scheduleId, AlarmStopAction action) async { } void triggerTimer(int scheduleId, Json params) async { + logger.i("Timer triggered $scheduleId"); ClockTimer? timer = getTimerById(scheduleId); if (timer == null || !timer.isRunning) { @@ -194,18 +209,12 @@ void triggerTimer(int scheduleId, Json params) async { } void stopTimer(int scheduleId, AlarmStopAction action) async { + logger.i("Stopping timer $scheduleId with action: ${action.name}"); ClockTimer? timer = getTimerById(scheduleId); if (timer == null) return; if (action == AlarmStopAction.snooze) { - await scheduleSnoozeAlarm( - scheduleId, - Duration(minutes: timer.addLength.floor()), - ScheduledNotificationType.timer, - "stopTimer(): ${timer.addLength.floor()} added to timer", - ); updateTimerById(scheduleId, (timer) async { - timer.setTime(const TimeDuration(minutes: 1)); - await timer.start(); + await timer.snooze(); }); } else if (action == AlarmStopAction.dismiss) { // If there was an alarm already ringing when the timer was triggered, we diff --git a/lib/alarm/logic/alarm_time.dart b/lib/alarm/logic/alarm_time.dart index 568141b4..94061c5b 100644 --- a/lib/alarm/logic/alarm_time.dart +++ b/lib/alarm/logic/alarm_time.dart @@ -4,26 +4,34 @@ import 'package:clock_app/common/utils/date_time.dart'; // Calculates the DateTime when the provided `time` will next occur DateTime getDailyAlarmDate( Time time, { - DateTime? scheduledDate, + DateTime? scheduleStartDate, + int interval = 1, }) { - if (scheduledDate != null && scheduledDate.isAfter(DateTime.now())) { - return DateTime(scheduledDate.year, scheduledDate.month, scheduledDate.day, - time.hour, time.minute, time.second); + if (scheduleStartDate != null && scheduleStartDate.isAfter(DateTime.now())) { + return DateTime(scheduleStartDate.year, scheduleStartDate.month, + scheduleStartDate.day, time.hour, time.minute, time.second); } // If a date has not been provided, assume it to be today - scheduledDate = DateTime.now(); + DateTime scheduleDate = DateTime.now(); DateTime alarmTime; - if (time.toHours() > scheduledDate.toHours()) { + if (time.toHours() > scheduleDate.toHours()) { // If the time is in the future, set the alarm for today - alarmTime = DateTime(scheduledDate.year, scheduledDate.month, - scheduledDate.day, time.hour, time.minute, time.second); + alarmTime = DateTime(scheduleDate.year, scheduleDate.month, + scheduleDate.day, time.hour, time.minute, time.second); } else { - // If the time has already passed, set the alarm for tomorrow - DateTime nextDateTime = scheduledDate.add(const Duration(days: 1)); - alarmTime = DateTime(nextDateTime.year, nextDateTime.month, - nextDateTime.day, time.hour, time.minute, time.second); + // If the time has already passed, set the alarm for next occurence + if (scheduleStartDate != null) { + scheduleDate = scheduleStartDate; + } + + while (scheduleDate.isBefore(DateTime.now())) { + scheduleDate = scheduleDate.add(Duration(days: interval)); + } + + alarmTime = DateTime(scheduleDate.year, scheduleDate.month, + scheduleDate.day, time.hour, time.minute, time.second); } return alarmTime; diff --git a/lib/alarm/logic/schedule_alarm.dart b/lib/alarm/logic/schedule_alarm.dart index 43e2916c..c2a41fc7 100644 --- a/lib/alarm/logic/schedule_alarm.dart +++ b/lib/alarm/logic/schedule_alarm.dart @@ -8,6 +8,7 @@ import 'package:clock_app/common/types/schedule_id.dart'; import 'package:clock_app/common/utils/date_time.dart'; import 'package:clock_app/common/utils/list_storage.dart'; import 'package:clock_app/common/utils/time_of_day.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:clock_app/settings/data/settings_schema.dart'; Future scheduleAlarm( @@ -68,7 +69,7 @@ Future scheduleAlarm( scheduleIds.add(ScheduleId(id: scheduleId)); await saveList(name, scheduleIds); - // + // // if (type == ScheduledNotificationType.alarm && !snooze) { // } // @@ -88,8 +89,10 @@ Future scheduleAlarm( 'type': type.name, }, ); + + logger.i('Scheduled alarm $scheduleId for $startDate of type ${type.name}: $description'); } - } +} Future cancelAlarm(int scheduleId, ScheduledNotificationType type) async { if (!Platform.environment.containsKey('FLUTTER_TEST')) { @@ -113,6 +116,8 @@ Future cancelAlarm(int scheduleId, ScheduledNotificationType type) async { } AndroidAlarmManager.cancel(scheduleId); + + logger.i('Canceled alarm $scheduleId of type ${type.name}'); } } @@ -128,4 +133,6 @@ Future scheduleSnoozeAlarm(int scheduleId, Duration delay, if (!Platform.environment.containsKey('FLUTTER_TEST')) { await createSnoozeNotification(scheduleId, DateTime.now().add(delay)); } + + logger.i('Scheduled snooze alarm $scheduleId for ${DateTime.now().add(delay)} with type ${type.name}: $description'); } diff --git a/lib/alarm/screens/alarm_notification_screen.dart b/lib/alarm/screens/alarm_notification_screen.dart index 89ad197b..3e3e3030 100644 --- a/lib/alarm/screens/alarm_notification_screen.dart +++ b/lib/alarm/screens/alarm_notification_screen.dart @@ -1,8 +1,12 @@ +import 'dart:ui'; + +import 'package:clock_app/alarm/logic/alarm_isolate.dart'; import 'package:clock_app/alarm/logic/update_alarms.dart'; import 'package:clock_app/alarm/utils/alarm_id.dart'; import 'package:clock_app/alarm/types/alarm.dart'; import 'package:clock_app/common/types/notification_type.dart'; import 'package:clock_app/common/widgets/clock/clock.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:clock_app/navigation/types/routes.dart'; import 'package:clock_app/notifications/types/fullscreen_notification_manager.dart'; import 'package:clock_app/navigation/types/alignment.dart'; @@ -37,6 +41,8 @@ class _AlarmNotificationScreenState extends State { void _setNextWidget() { setState(() { if (_currentIndex < 0) { + IsolateNameServer.lookupPortByName(setAlarmVolumePortName) + ?.send([alarm.volume]); _currentWidget = actionWidget; } else if (_currentIndex >= alarm.tasks.length) { if (widget.onPop != null) { @@ -47,6 +53,8 @@ class _AlarmNotificationScreenState extends State { widget.dismissType, ScheduledNotificationType.alarm); } } else { + IsolateNameServer.lookupPortByName(setAlarmVolumePortName) + ?.send([alarm.volume * alarm.volumeDuringTasks / 100]); // RingtonePlayer.setVolume(0); _currentWidget = alarm.tasks[_currentIndex].builder(_setNextWidget); } @@ -81,7 +89,7 @@ class _AlarmNotificationScreenState extends State { snoozeLabel: "Snooze", ); - debugPrint(e.toString()); + logger.e(e.toString()); } _setNextWidget(); diff --git a/lib/alarm/screens/alarm_screen.dart b/lib/alarm/screens/alarm_screen.dart index 4e7aba13..00168fe7 100644 --- a/lib/alarm/screens/alarm_screen.dart +++ b/lib/alarm/screens/alarm_screen.dart @@ -11,13 +11,12 @@ import 'package:clock_app/common/types/list_filter.dart'; import 'package:clock_app/common/types/picker_result.dart'; import 'package:clock_app/common/types/time.dart'; import 'package:clock_app/common/utils/snackbar.dart'; -import 'package:clock_app/common/widgets/card_container.dart'; import 'package:clock_app/common/widgets/fab.dart'; import 'package:clock_app/common/widgets/list/customize_list_item_screen.dart'; import 'package:clock_app/common/widgets/list/persistent_list_view.dart'; import 'package:clock_app/common/widgets/time_picker.dart'; +import 'package:clock_app/navigation/types/quick_action_controller.dart'; import 'package:clock_app/settings/data/settings_schema.dart'; -import 'package:clock_app/settings/types/listener_manager.dart'; import 'package:clock_app/settings/types/setting.dart'; import 'package:flutter/material.dart'; import 'package:great_list_view/great_list_view.dart'; @@ -30,7 +29,9 @@ typedef AlarmCardBuilder = Widget Function( ); class AlarmScreen extends StatefulWidget { - const AlarmScreen({super.key}); + const AlarmScreen({super.key, this.actionController}); + + final QuickActionController? actionController; @override State createState() => _AlarmScreenState(); @@ -68,10 +69,16 @@ class _AlarmScreenState extends State { _showNextAlarm.addListener(update); _showSort.addListener(update); - ListenerManager.addOnChangeListener("alarms", update); + // ListenerManager.addOnChangeListener("alarms", update); nextAlarm = getNextAlarm(); + widget.actionController?.setAction((action) { + if (action == "add_alarm") { + _selectTime(); + } + }); + // ListenerManager().addListener(); } @@ -81,7 +88,7 @@ class _AlarmScreenState extends State { _showFilters.removeListener(update); _showSort.removeListener(update); _showNextAlarm.removeListener(update); - ListenerManager.removeOnChangeListener("alarms", update); + // ListenerManager.removeOnChangeListener("alarms", update); super.dispose(); } @@ -187,7 +194,13 @@ class _AlarmScreenState extends State { _listController.changeItems((alarms) {}); } - List> getListFilterItems() { + void handleAddAlarmActon(){ + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + _selectTime(); + + } + + List> _getListFilterItems() { List> listFilterItems = _showFilters.value ? [...alarmListFilters] : []; @@ -205,31 +218,31 @@ class _AlarmScreenState extends State { return listFilterItems; } - @override - Widget build(BuildContext context) { - Future selectTime() async { - final PickerResult? timePickerResult = - await showTimePickerDialog( - context: context, - initialTime: TimeOfDay.now(), - title: AppLocalizations.of(context)!.selectTime, - cancelText: AppLocalizations.of(context)!.cancelButton, - confirmText: AppLocalizations.of(context)!.saveButton, - useSimple: false, - ); - - if (timePickerResult != null) { - Alarm alarm = Alarm.fromTimeOfDay(timePickerResult.value); - if (timePickerResult.isCustomize) { - await _openCustomizeAlarmScreen(alarm, onSave: (newAlarm) async { - _listController.addItem(newAlarm); - }, isNewAlarm: true); - } else { - _listController.addItem(alarm); - } + Future _selectTime() async { + final PickerResult? timePickerResult = + await showTimePickerDialog( + context: context, + initialTime: TimeOfDay.now(), + title: AppLocalizations.of(context)!.selectTime, + cancelText: AppLocalizations.of(context)!.cancelButton, + confirmText: AppLocalizations.of(context)!.saveButton, + useSimple: false, + ); + + if (timePickerResult != null) { + Alarm alarm = Alarm.fromTimeOfDay(timePickerResult.value); + if (timePickerResult.isCustomize) { + await _openCustomizeAlarmScreen(alarm, onSave: (newAlarm) async { + _listController.addItem(newAlarm); + }, isNewAlarm: true); + } else { + _listController.addItem(alarm); } } + } + @override + Widget build(BuildContext context) { return Stack( children: [ PersistentListView( @@ -257,8 +270,9 @@ class _AlarmScreenState extends State { nextAlarm = getNextAlarm(); setState(() {}); }, + isSelectable: true, // header: getNextAlarmWidget(), - listFilters: getListFilterItems(), + listFilters: _getListFilterItems(), customActions: _showFilters.value ? [ ListFilterCustomAction( @@ -294,11 +308,8 @@ class _AlarmScreenState extends State { sortOptions: _showSort.value ? alarmSortOptions : [], ), FAB( - onPressed: () { - ScaffoldMessenger.of(context).removeCurrentSnackBar(); - selectTime(); - }, - ), + onPressed: handleAddAlarmActon, + ), if (_showInstantAlarmButton.value) FAB( onPressed: () { diff --git a/lib/alarm/types/alarm.dart b/lib/alarm/types/alarm.dart index 971b80ad..6848cce5 100644 --- a/lib/alarm/types/alarm.dart +++ b/lib/alarm/types/alarm.dart @@ -81,6 +81,7 @@ class Alarm extends CustomizableListItem { FileItem get ringtone => _settings.getSetting("Melody").value; bool get vibrate => _settings.getSetting("Vibration").value; double get volume => _settings.getSetting("Volume").value; + double get volumeDuringTasks => _settings.getSetting("task_volume").value; double get snoozeLength => _settings.getSetting("Length").value; List get tasks => _settings.getSetting("Tasks").value; List get tags => _settings.getSetting("Tags").value; @@ -135,7 +136,6 @@ class Alarm extends CustomizableListItem { Alarm.fromAlarm(Alarm alarm) : _isEnabled = alarm._isEnabled, - // _isFinished = alarm._isFinished, _time = alarm._time, _snoozeCount = alarm._snoozeCount, _snoozeTime = alarm._snoozeTime, @@ -148,7 +148,6 @@ class Alarm extends CustomizableListItem { @override void copyFrom(dynamic other) { _isEnabled = other._isEnabled; - // _isFinished = other._isFinished; _time = other._time; _snoozeCount = other._snoozeCount; _snoozeTime = other._snoozeTime; diff --git a/lib/alarm/types/alarm_event.dart b/lib/alarm/types/alarm_event.dart index db66c538..fc65ef7c 100644 --- a/lib/alarm/types/alarm_event.dart +++ b/lib/alarm/types/alarm_event.dart @@ -1,7 +1,7 @@ import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/list_item.dart'; import 'package:clock_app/common/types/notification_type.dart'; -import 'package:flutter/foundation.dart'; +import 'package:clock_app/common/utils/id.dart'; // enum AlarmEventType{ // schedule, @@ -26,7 +26,7 @@ class AlarmEvent extends ListItem { required this.scheduleId, required this.startDate, required this.isActive, - }) : id = UniqueKey().hashCode; + }) : id = getId(); AlarmEvent.fromJson(Json json) { if (json == null) { diff --git a/lib/alarm/types/alarm_runner.dart b/lib/alarm/types/alarm_runner.dart index 2d7e0b20..5043ba2d 100644 --- a/lib/alarm/types/alarm_runner.dart +++ b/lib/alarm/types/alarm_runner.dart @@ -1,17 +1,17 @@ import 'package:clock_app/alarm/logic/schedule_alarm.dart'; import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/notification_type.dart'; -import 'package:flutter/material.dart'; +import 'package:clock_app/common/utils/id.dart'; class AlarmRunner extends JsonSerializable { late int _id; DateTime? _currentScheduleDateTime; - int get id => _id; + get id => _id; DateTime? get currentScheduleDateTime => _currentScheduleDateTime; AlarmRunner() { - _id = UniqueKey().hashCode; + _id = getId(); } Future schedule(DateTime dateTime, String description) async { @@ -27,10 +27,10 @@ class AlarmRunner extends JsonSerializable { AlarmRunner.fromJson(Json? json) { if (json == null) { - _id = UniqueKey().hashCode; + _id = getId(); return; } - _id = json['id'] ?? UniqueKey().hashCode; + _id = json['id'] ?? getId(); int millisecondsSinceEpoch = json['currentScheduleDateTime'] ?? 0; _currentScheduleDateTime = millisecondsSinceEpoch == 0 ? null diff --git a/lib/alarm/types/alarm_task.dart b/lib/alarm/types/alarm_task.dart index a1c83c02..24daea1f 100644 --- a/lib/alarm/types/alarm_task.dart +++ b/lib/alarm/types/alarm_task.dart @@ -1,6 +1,7 @@ import 'package:clock_app/alarm/data/alarm_task_schemas.dart'; import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/list_item.dart'; +import 'package:clock_app/common/utils/id.dart'; import 'package:clock_app/settings/types/setting_group.dart'; import 'package:flutter/material.dart'; @@ -55,21 +56,21 @@ class AlarmTask extends CustomizableListItem { AlarmTask(this.type) : _schema = alarmTaskSchemasMap[type]!.copy(), - _id = UniqueKey().hashCode; + _id = getId(); AlarmTask.from(AlarmTask task) : type = task.type, - _id = UniqueKey().hashCode, + _id = getId(), _schema = task._schema.copy(); AlarmTask.fromJson(Json json) { if (json == null) { - _id = UniqueKey().hashCode; + _id = getId(); type = AlarmTaskType.math; _schema = alarmTaskSchemasMap[type]!.copy(); return; } - _id = json['id'] ?? UniqueKey().hashCode; + _id = json['id'] ?? getId(); type = AlarmTaskType.values.byName(json['type']); _schema = alarmTaskSchemasMap[type]!.copy(); _schema.loadFromJson(json['schema']); diff --git a/lib/alarm/types/schedules/range_alarm_schedule.dart b/lib/alarm/types/schedules/range_alarm_schedule.dart index e5b00105..b17dcb62 100644 --- a/lib/alarm/types/schedules/range_alarm_schedule.dart +++ b/lib/alarm/types/schedules/range_alarm_schedule.dart @@ -43,15 +43,14 @@ class RangeAlarmSchedule extends AlarmSchedule { @override Future schedule(Time time, String description) async { + int intervalDays = interval == RangeInterval.daily ? 1 : 7; // All the dates are not scheduled at once // Instead we schedule the next date after the current one is finished - - DateTime alarmDate = getDailyAlarmDate(time, scheduledDate: startDate); - print('$alarmDate $startDate $endDate'); + DateTime alarmDate = getDailyAlarmDate(time, + scheduleStartDate: startDate, interval: intervalDays); if (alarmDate.isAfter(endDate)) { _isFinished = true; } else { - print("_____________"); await _alarmRunner.schedule(alarmDate, description); _isFinished = false; } diff --git a/lib/alarm/types/schedules/weekly_alarm_schedule.dart b/lib/alarm/types/schedules/weekly_alarm_schedule.dart index e12c504a..222d11e1 100644 --- a/lib/alarm/types/schedules/weekly_alarm_schedule.dart +++ b/lib/alarm/types/schedules/weekly_alarm_schedule.dart @@ -89,6 +89,9 @@ class WeeklyAlarmSchedule extends AlarmSchedule { weekdaySchedule.alarmRunner.cancel(); } + // We schedule the next occurence for each weekday. + // Subsequent occurences will be scheduled after the first one passes. + List weekdays = _weekdaySetting.selected.toList(); List existingWeekdays = _weekdaySchedules.map((schedule) => schedule.weekday).toList(); diff --git a/lib/alarm/utils/next_alarm.dart b/lib/alarm/utils/next_alarm.dart index ebb8eaf3..ee0658c6 100644 --- a/lib/alarm/utils/next_alarm.dart +++ b/lib/alarm/utils/next_alarm.dart @@ -1,10 +1,7 @@ - - import 'package:clock_app/alarm/types/alarm.dart'; -import 'package:clock_app/common/utils/json_serialize.dart'; import 'package:clock_app/common/utils/list_storage.dart'; -Alarm? getNextAlarm () { +Alarm? getNextAlarm() { List alarms = loadListSync('alarms'); if (alarms.isEmpty) return null; alarms.sort((a, b) { diff --git a/lib/app.dart b/lib/app.dart index c191902c..3c6da52f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -22,6 +22,7 @@ import 'package:clock_app/widgets/logic/update_widgets.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:get_storage/get_storage.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -68,7 +69,7 @@ class _AppState extends State { _styleSettings = _appearanceSettings.getGroup("Style"); _generalSettings = appSettings.getGroup("General"); _animationSpeedSetting = - _generalSettings.getGroup("Animations").getSetting("Animation Speed"); + _appearanceSettings.getGroup("Animations").getSetting("Animation Speed"); _animationSpeedSetting.addListener(setAnimationSpeed); setAnimationSpeed(_animationSpeedSetting.value); diff --git a/lib/audio/types/ringtone_player.dart b/lib/audio/types/ringtone_player.dart index d5742ad2..091c201e 100644 --- a/lib/audio/types/ringtone_player.dart +++ b/lib/audio/types/ringtone_player.dart @@ -1,6 +1,7 @@ import 'package:audio_session/audio_session.dart'; import 'package:clock_app/alarm/types/alarm.dart'; import 'package:clock_app/audio/types/ringtone_manager.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:clock_app/timer/types/timer.dart'; import 'package:just_audio/just_audio.dart'; import 'package:vibration/vibration.dart'; @@ -98,6 +99,7 @@ class RingtonePlayer { } static Future setVolume(double volume) async { + logger.t("Setting volume to $volume"); await activePlayer?.setVolume(volume); } @@ -117,15 +119,15 @@ class RingtonePlayer { await activePlayer?.stop(); await activePlayer?.setLoopMode(loopMode); await activePlayer?.setAudioSource(AudioSource.uri(Uri.parse(ringtoneUri))); - await activePlayer?.setVolume(volume); - // activePlayer.setMode + await setVolume(volume); + // Gradually increase the volume if (secondsToMaxVolume > 0) { for (int i = 0; i <= 10; i++) { Future.delayed( Duration(milliseconds: i * (secondsToMaxVolume * 100)), () { - activePlayer?.setVolume((i / 10) * volume); + setVolume((i / 10) * volume); }, ); } diff --git a/lib/clock/logic/timezone_database.dart b/lib/clock/logic/timezone_database.dart index c6435da2..ab490e1a 100644 --- a/lib/clock/logic/timezone_database.dart +++ b/lib/clock/logic/timezone_database.dart @@ -1,8 +1,12 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:clock_app/debug/logic/logger.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:clock_app/common/data/paths.dart'; +import 'package:path/path.dart'; // Database? database; Future initializeDatabases() async { @@ -12,10 +16,11 @@ Future initializeDatabases() async { if (FileSystemEntity.typeSync(timezonesDatabasePath) == FileSystemEntityType.notFound) { // Load database from asset and copy - ByteData data = await rootBundle.load('assets/timezones.db'); + ByteData data = await rootBundle.load(join('assets', 'timezones.db')); List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + logger.i('Copying timzones.db to $timezonesDatabasePath'); // Save copied asset to documents await File(timezonesDatabasePath).writeAsBytes(bytes); } diff --git a/lib/clock/screens/clock_screen.dart b/lib/clock/screens/clock_screen.dart index b6a28a26..38668467 100644 --- a/lib/clock/screens/clock_screen.dart +++ b/lib/clock/screens/clock_screen.dart @@ -73,6 +73,7 @@ class _ClockScreenState extends State { onDelete: () => _listController.deleteItem(city)), placeholderText: "No cities added", isDuplicateEnabled: false, + isSelectable: true, ), ), ]), diff --git a/lib/clock/types/city.dart b/lib/clock/types/city.dart index 58898031..63d688a5 100644 --- a/lib/clock/types/city.dart +++ b/lib/clock/types/city.dart @@ -1,6 +1,6 @@ import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/list_item.dart'; -import 'package:flutter/material.dart'; +import 'package:clock_app/common/utils/id.dart'; class City extends ListItem { late String _name = "Unknown"; @@ -17,7 +17,7 @@ class City extends ListItem { @override bool get isDeletable => true; - City(this._name, this._country, this._timezone) : _id = UniqueKey().hashCode; + City(this._name, this._country, this._timezone) : _id = getId(); @override copy() { @@ -26,13 +26,13 @@ class City extends ListItem { City.fromJson(Json json) { if (json == null) { - _id = UniqueKey().hashCode; + _id = getId(); return; } _name = json['name'] ?? 'Unknown'; _country = json['country'] ?? 'Unknown'; _timezone = json['timezone'] ?? 'America/Detroit'; - _id = json['id'] ?? UniqueKey().hashCode; + _id = json['id'] ?? getId(); } @override diff --git a/lib/common/data/animations.dart b/lib/common/data/animations.dart new file mode 100644 index 00000000..43484c46 --- /dev/null +++ b/lib/common/data/animations.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +extension AnimateWidgetExtensions on Widget { + Animate animateCard(dynamic key) => animate(delay: 50.ms, key: key) + .slideY(begin: 0.15, end: 0, duration: 150.ms, curve: Curves.easeOut) + .fade(duration: 150.ms, curve: Curves.easeOut); + +} + +extension AnimateListExtensions on List { + /// Wraps the target `List` in an [AnimateList] instance, and returns + /// the instance for chaining calls. + /// Ex. `[foo, bar].animate()` is equivalent to `AnimateList(children: [foo, bar])`. + AnimateList animateCardList() => animate(interval: 100.ms) + .slideY(begin: 0.15, end: 0, duration: 150.ms, curve: Curves.easeOut) + .fade(duration: 150.ms, curve: Curves.easeOut); +} diff --git a/lib/common/data/paths.dart b/lib/common/data/paths.dart index b908ad93..d670eb47 100644 --- a/lib/common/data/paths.dart +++ b/lib/common/data/paths.dart @@ -41,3 +41,7 @@ String getRingtonesDirectoryPathSync() { Future getTimezonesDatabasePath() async { return path.join(await getAppDataDirectoryPath(), 'timezones.db'); } + +Future getLogsFilePath() async { + return path.join(await getAppDataDirectoryPath(), "logs.txt"); +} diff --git a/lib/common/logic/card_decoration.dart b/lib/common/logic/card_decoration.dart index 28246179..71e84180 100644 --- a/lib/common/logic/card_decoration.dart +++ b/lib/common/logic/card_decoration.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; BoxDecoration getCardDecoration(BuildContext context, {Color? color, bool showLightBorder = false, + bool isSelected = false, showShadow = true, elevationMultiplier = 1, blurStyle = BlurStyle.normal}) { @@ -12,7 +13,12 @@ BoxDecoration getCardDecoration(BuildContext context, ThemeStyleExtension? themeStyle = theme.extension(); return BoxDecoration( - border: showLightBorder + border: isSelected ? Border.all( + color: colorScheme.primary, + width: 1, + strokeAlign: BorderSide.strokeAlignOutside + + ) : showLightBorder ? Border.all( color: colorScheme.outline.withOpacity(0.2), width: 0.5, diff --git a/lib/common/logic/show_select.dart b/lib/common/logic/show_select.dart index b060d6c8..c8ddcb5b 100644 --- a/lib/common/logic/show_select.dart +++ b/lib/common/logic/show_select.dart @@ -1,6 +1,7 @@ import 'package:clock_app/common/types/popup_action.dart'; import 'package:clock_app/common/types/select_choice.dart'; import 'package:clock_app/common/widgets/fields/select_field/select_bottom_sheet.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:flutter/material.dart'; Future showSelectBottomSheet( @@ -40,7 +41,7 @@ Future showSelectBottomSheet( if (indices.length == 1) { currentSelectedIndices = [indices[0]]; } else { - debugPrint("Too many indices"); + logger.e("Too many indices in select bottom sheet"); } } }); diff --git a/lib/common/types/file_item.dart b/lib/common/types/file_item.dart index a8bec429..d95a7e1d 100644 --- a/lib/common/types/file_item.dart +++ b/lib/common/types/file_item.dart @@ -2,8 +2,7 @@ import 'dart:convert'; import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/list_item.dart'; -import 'package:clock_app/common/types/timer_state.dart'; -import 'package:flutter/material.dart'; +import 'package:clock_app/common/utils/id.dart'; enum FileItemType { audio, @@ -33,14 +32,14 @@ class FileItem extends ListItem { bool get isDeletable => _isDeletable; FileItem(this.name, this._uri, this._type, {isDeletable = true}) - : _id = UniqueKey().hashCode, + : _id = getId(), _isDeletable = isDeletable; @override FileItem.fromJson(Json json) : _id = json != null - ? json['id'] ?? UniqueKey().hashCode - : UniqueKey().hashCode, + ? json['id'] ?? getId() + : getId(), _type = json != null ? json['type'] != null ? FileItemType.values diff --git a/lib/common/types/list_filter.dart b/lib/common/types/list_filter.dart index 7b7abe59..aefdafa4 100644 --- a/lib/common/types/list_filter.dart +++ b/lib/common/types/list_filter.dart @@ -1,5 +1,6 @@ import 'package:clock_app/common/types/list_item.dart'; -import 'package:clock_app/common/utils/debug.dart'; +import 'package:clock_app/common/utils/id.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -37,7 +38,7 @@ class ListFilter extends ListFilterItem { ListFilter(this.getLocalizedName, bool Function(Item) filterFunction, {int? id}) - : _id = id ?? UniqueKey().hashCode, + : _id = id ?? getId(), _filterFunction = filterFunction; int get id => _id; @@ -198,7 +199,7 @@ abstract class FilterSelect try { return selectedFilter.filterFunction; } catch (e) { - printDebug("Error in getting filter function($displayName): $e"); + logger.d("Error in getting filter function($displayName): $e"); return (Item item) => true; } } diff --git a/lib/common/types/tag.dart b/lib/common/types/tag.dart index 9a921aeb..d665fbfe 100644 --- a/lib/common/types/tag.dart +++ b/lib/common/types/tag.dart @@ -1,5 +1,6 @@ import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/list_item.dart'; +import 'package:clock_app/common/utils/id.dart'; import 'package:flutter/material.dart'; class Tag extends ListItem { @@ -8,16 +9,16 @@ class Tag extends ListItem { String description; Color color; Tag(this.name, {this.description = "", this.color = Colors.blue}) - : _id = UniqueKey().hashCode; + : _id = getId(); Tag.fromJson(Json json) - : _id = json?['id'] ?? UniqueKey().hashCode, + : _id = json?['id'] ?? getId(), name = json?['name'] ?? "Unknown", description = json?['description'] ?? "", color = Color(json?['color'] ?? 0); Tag.from(Tag tag) - : _id = UniqueKey().hashCode, + : _id = getId(), name = tag.name, description = tag.description, color = tag.color; diff --git a/lib/common/utils/debug.dart b/lib/common/utils/debug.dart deleted file mode 100644 index f47f7237..00000000 --- a/lib/common/utils/debug.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'dart:isolate'; - -import 'package:flutter/foundation.dart'; - -void printIsolateInfo() { - printDebug( - "Isolate: ${Isolate.current.debugName}, id: ${Isolate.current.hashCode}"); -} - -void printDebug(String message) { - if (kDebugMode) { - print(message); - } -} diff --git a/lib/common/utils/id.dart b/lib/common/utils/id.dart new file mode 100644 index 00000000..36c50288 --- /dev/null +++ b/lib/common/utils/id.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +int getId() { + return UniqueKey().hashCode; +} diff --git a/lib/common/utils/json_serialize.dart b/lib/common/utils/json_serialize.dart index c052c95f..562335aa 100644 --- a/lib/common/utils/json_serialize.dart +++ b/lib/common/utils/json_serialize.dart @@ -9,6 +9,7 @@ import 'package:clock_app/common/types/tag.dart'; import 'package:clock_app/common/types/time.dart'; import 'package:clock_app/clock/types/city.dart'; import 'package:clock_app/common/types/json.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:clock_app/stopwatch/types/lap.dart'; import 'package:clock_app/stopwatch/types/stopwatch.dart'; import 'package:clock_app/theme/types/color_scheme.dart'; @@ -51,7 +52,7 @@ List listFromString(String encodedItems) { List list = rawList.map((json) => fromJson(json)).toList(); return list; } catch (e) { - debugPrint("Error decoding string: ${e.toString()}"); + logger.e("Error decoding string: ${e.toString()}"); rethrow; } } diff --git a/lib/common/utils/list_storage.dart b/lib/common/utils/list_storage.dart index 71383cf5..0743ff40 100644 --- a/lib/common/utils/list_storage.dart +++ b/lib/common/utils/list_storage.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:clock_app/common/data/paths.dart'; import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/utils/json_serialize.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:flutter/material.dart'; import 'package:get_storage/get_storage.dart'; import 'package:path/path.dart' as path; @@ -50,7 +51,7 @@ List loadListSync(String key) { try{ return listFromString(loadTextFileSync(key)); }catch(e){ - debugPrint("Error loading list ($key): $e"); + logger.e("Error loading list ($key): $e"); return []; } } @@ -74,7 +75,7 @@ Future initTextFile(String key, String value) async { if (GetStorage().read('init_$key') == null) { GetStorage().write('init_$key', true); if(!textFileExistsSync(key)){ - debugPrint("Initializing $key"); + logger.i("Initializing $key"); await saveTextFile(key, value); } } diff --git a/lib/common/utils/snackbar.dart b/lib/common/utils/snackbar.dart index 267c2dfd..f99b2a6a 100644 --- a/lib/common/utils/snackbar.dart +++ b/lib/common/utils/snackbar.dart @@ -2,13 +2,17 @@ import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:flutter/material.dart'; void showSnackBar(BuildContext context, String text, - {bool fab = false, bool navBar = false}) { + {bool fab = false, bool navBar = false, bool error = false}) { + ThemeData theme = Theme.of(context); + ColorScheme colorScheme = theme.colorScheme; + Color? color = error ? colorScheme.error : null; ScaffoldMessenger.of(context).removeCurrentSnackBar(); ScaffoldMessenger.of(context) - .showSnackBar(getSnackbar(text, fab: fab, navBar: navBar)); + .showSnackBar(getSnackbar(text, fab: fab, navBar: navBar, color: color)); } -SnackBar getSnackbar(String text, {bool fab = false, bool navBar = false}) { +SnackBar getSnackbar(String text, + {bool fab = false, bool navBar = false, Color? color}) { double left = 20; double right = 20; double bottom = 12; @@ -43,7 +47,9 @@ SnackBar getSnackbar(String text, {bool fab = false, bool navBar = false}) { content: ConstrainedBox( constraints: const BoxConstraints(minHeight: 28), child: Container( + padding: const EdgeInsets.all(16), alignment: Alignment.centerLeft, + color: color, // height: 28, child: Text(text), ), @@ -53,6 +59,7 @@ SnackBar getSnackbar(String text, {bool fab = false, bool navBar = false}) { right: right, bottom: bottom, ), + padding: EdgeInsets.zero, elevation: 2, dismissDirection: DismissDirection.none, ); diff --git a/lib/common/widgets/animated_show_hide.dart b/lib/common/widgets/animated_show_hide.dart new file mode 100644 index 00000000..2a78adbe --- /dev/null +++ b/lib/common/widgets/animated_show_hide.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; + +/// A typedef for a custom animation transition builder used by +/// [AnimatedShowHide] and [AnimatedShowHideChild]. +/// +/// The [AnimatedShowHideTransitionBuilder] typedef represents a function that +/// takes the current build context, an animation object, and the child widget as +/// arguments and returns a widget. This function allows for custom animation +/// transitions when showing or hiding a child widget. +/// +/// The animation object provides information about the current state of the +/// animation, including the value, which ranges from 0.0 to 1.0. You can use +/// this information to control the appearance and behavior of the child widget +/// during the transition. +/// +/// {@tool snippet} +/// This example shows how to use a custom animation transition builder to +/// create a fade-in/fade-out animation. +/// +/// ```dart +/// AnimatedShowHide( +/// child: const Text('Hello World!'), +/// transitionBuilder: (context, animation, child) { +/// return FadeTransition( +/// opacity: animation, +/// child: child, +/// ); +/// }, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [AnimatedShowHide] +/// * [AnimatedShowHideChild] +/// * [FadeTransition] +typedef AnimatedShowHideTransitionBuilder = Widget Function( + BuildContext context, + Animation animation, + Widget? child, +); + +/// A widget that manages the showing and hiding of a child widget based on +/// animation. +/// +/// The [AnimatedShowHide] widget uses an [AnimationController] to animate the +/// showing and hiding of its child widget. The animation is controlled by the +/// [animate] property, which determines whether the child widget should be shown +/// or hidden. +/// +/// The animation can be customized using the [duration], [curve], [axis], and +/// [axisAlignment] properties. The [transitionBuilder] property can be used to +/// provide a custom animation transition. +/// +/// {@tool snippet} +/// This example shows how to use the [AnimatedShowHide] widget to animate the +/// showing and hiding of a child widget. +/// +/// ```dart +/// AnimatedShowHide( +/// child: const Text('Hello World!'), +/// animate: true, +/// duration: const Duration(seconds: 1), +/// curve: Curves.bounceInOut, +/// axis: Axis.horizontal, +/// axisAlignment: 0.5, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [AnimatedShowHideChild] +/// * [AnimatedShowHideTransitionBuilder] +/// * [SizeTransition] +/// * [FadeTransition] +class AnimatedShowHide extends StatelessWidget { + /// Creates a new [AnimatedShowHide] widget. + /// + /// The [child] property is the widget to be shown or hidden. The [animate] + /// property determines whether the child widget should be shown or hidden. The + /// [duration], [curve], [axis], and [axisAlignment] properties can be used to + /// customize the animation. The [transitionBuilder] property can be used to + /// provide a custom animation transition. + const AnimatedShowHide({ + this.child, + this.animate = true, + this.duration = const Duration(milliseconds: 180), + this.curve = Curves.ease, + this.axis = Axis.vertical, + this.axisAlignment = -1, + this.transitionBuilder, + super.key, + }); + + /// The widget to be shown or hidden. + final Widget? child; + + /// Whether to animate the showing and hiding of the child widget. + final bool animate; + + /// The duration of the animation. + final Duration duration; + + /// The curve of the animation. + final Curve curve; + + /// The axis of the animation. + final Axis axis; + + /// The axis alignment of the animation. + final double axisAlignment; + + /// A custom animation transition builder. + final AnimatedShowHideTransitionBuilder? transitionBuilder; + + Widget buildAnimationWidget(BuildContext context) { + return AnimatedShowHideChild( + transitionBuilder: transitionBuilder, + duration: duration, + curve: curve, + axis: axis, + axisAlignment: axisAlignment, + child: child, + ); + } + + @override + Widget build(BuildContext context) { + if (animate) { + return buildAnimationWidget(context); + } + return child ?? const SizedBox(); + } +} + +/// A widget that manages the showing and hiding of a child widget based on animation. +/// +/// The [AnimatedShowHideChild] widget uses an [AnimationController] to animate the +/// showing and hiding of its child widget. +/// +/// The animation can be customized using the [duration], [curve], [axis], and +/// [axisAlignment] properties. The [transitionBuilder] property can be used to +/// provide a custom animation transition. +/// +/// {@tool snippet} +/// This example shows how to use the [AnimatedShowHideChild] widget to animate the +/// showing and hiding of a child widget. +/// +/// ```dart +/// AnimatedShowHideChild( +/// child: show ? const Text('Hello World!') : null, +/// animate: true, +/// duration: const Duration(seconds: 1), +/// curve: Curves.bounceInOut, +/// axis: Axis.horizontal, +/// axisAlignment: 0.5, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [AnimatedShowHide] +/// * [AnimatedShowHideTransitionBuilder] +/// * [SizeTransition] +/// * [FadeTransition] +class AnimatedShowHideChild extends StatefulWidget { + /// Creates a new [AnimatedShowHideChild] widget. + /// + /// The [child] property is the widget to be shown or hidden. The + /// [duration], [curve], [axis], and [axisAlignment] properties can be used to + /// customize the animation. The [transitionBuilder] property can be used to + /// provide a custom animation transition. + const AnimatedShowHideChild({ + this.child, + this.duration = const Duration(milliseconds: 180), + this.curve = Curves.ease, + this.axis = Axis.vertical, + this.axisAlignment = -1, + this.transitionBuilder, + super.key, + }); + + /// The widget to be shown or hidden. + final Widget? child; + + /// The duration of the animation. + final Duration duration; + + /// The curve of the animation. + final Curve curve; + + /// The axis of the animation. + final Axis axis; + + /// The axis alignment of the animation. + final double axisAlignment; + + /// A custom animation transition builder. + final AnimatedShowHideTransitionBuilder? transitionBuilder; + + @override + State createState() => _AnimatedShowHideChildState(); +} + +class _AnimatedShowHideChildState extends State + with SingleTickerProviderStateMixin { + AnimationController? controller; + late Animation animation; + + void _listener() { + if (controller?.isDismissed ?? false) { + setState(() { + outGoingChild = const SizedBox(); + }); + } + } + + Widget outGoingChild = const SizedBox(); + + @override + void initState() { + controller ??= AnimationController(vsync: this, duration: widget.duration); + controller!.addListener(_listener); + animation = CurvedAnimation( + parent: controller!.drive(Tween(begin: 0, end: 1)), + curve: widget.curve, + ); + controller!.forward(); + super.initState(); + } + + @override + void dispose() { + controller?.removeListener(_listener); + controller?.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant AnimatedShowHideChild oldWidget) { + super.didUpdateWidget(oldWidget); + animatedOnChanges(oldWidget); + } + + // This method manages the changes in the animated widget and ensures the appropriate actions are taken based on the properties of the current and previous widgets. + // + // If the `transitionBuilder` property of the current widget is null, it checks the `child` property of the previous widget. If the previous child is not null, it sets `_outGoingChild` to the previous child; otherwise, it sets it to `SizedBox()`. + // + // If the `child` property of the current widget is null, it calls `reverse()` on `_controller`; otherwise, it calls `forward()`. + // + // If the `transitionBuilder` property is not null, it checks and sets `_outGoingChild` based on the transition builder's call with the context, animation, and child properties. It then decides whether to call `reverse()` or `forward()` on `_controller` based on the transition builder's call result. + void animatedOnChanges(covariant AnimatedShowHideChild oldWidget) { + if (widget.transitionBuilder == null) { + if (oldWidget.child != null) { + outGoingChild = oldWidget.child ?? const SizedBox(); + } + if (widget.child == null) { + controller?.reverse(); + } else { + controller?.forward(); + } + } else { + if (oldWidget.transitionBuilder?.call(context, animation, widget.child) != + null) { + outGoingChild = oldWidget.transitionBuilder + ?.call(context, animation, widget.child) ?? + const SizedBox(); + } + if (widget.transitionBuilder?.call(context, animation, widget.child) == + null) { + controller?.reverse(); + } else { + controller?.forward(); + } + } + } + + @override + Widget build(BuildContext context) { + if (widget.transitionBuilder != null) { + return widget.transitionBuilder!( + context, + animation, + widget.child, + ); + } + return SizeTransition( + sizeFactor: animation, + axisAlignment: widget.axisAlignment, + axis: widget.axis, + child: widget.child ?? outGoingChild, + ); + } +} diff --git a/lib/common/widgets/card_container.dart b/lib/common/widgets/card_container.dart index b430f80c..0cf3bea9 100644 --- a/lib/common/widgets/card_container.dart +++ b/lib/common/widgets/card_container.dart @@ -5,13 +5,12 @@ import 'package:clock_app/common/utils/color.dart'; import 'package:material_color_utilities/hct/hct.dart'; import 'package:material_color_utilities/palettes/tonal_palette.dart'; - TonalPalette toTonalPalette(int value) { final color = Hct.fromInt(value); return TonalPalette.of(color.hue, color.chroma); } -Color getCardColor(BuildContext context, [Color? color]){ +Color getCardColor(BuildContext context, [Color? color]) { ColorScheme colorScheme = Theme.of(context).colorScheme; bool useMaterialYou = appSettings .getGroup("Appearance") @@ -22,10 +21,10 @@ Color getCardColor(BuildContext context, [Color? color]){ TonalPalette tonalPalette = toTonalPalette(colorScheme.surface.value); return color ?? - (useMaterialYou - ? Color(tonalPalette.get( - Theme.of(context).brightness == Brightness.light ? 96 : 15)) - : colorScheme.surface); + (useMaterialYou + ? Color(tonalPalette + .get(Theme.of(context).brightness == Brightness.light ? 96 : 15)) + : colorScheme.surface); } class CardContainer extends StatelessWidget { @@ -38,9 +37,9 @@ class CardContainer extends StatelessWidget { this.onTap, this.alignment, this.showShadow = true, - + this.isSelected = false, this.showLightBorder = false, - this.blurStyle = BlurStyle.normal, + this.blurStyle = BlurStyle.normal, this.onLongPress, }); final Widget child; @@ -48,10 +47,12 @@ class CardContainer extends StatelessWidget { final Color? color; final EdgeInsetsGeometry? margin; final VoidCallback? onTap; + final VoidCallback? onLongPress; final Alignment? alignment; final bool showShadow; final BlurStyle blurStyle; final bool showLightBorder; + final bool isSelected; // TonalPalette primaryTonalP = toTonalPalette(_primaryColor); // primaryTonalP.get(50); // Getting the specific color @@ -68,12 +69,14 @@ class CardContainer extends StatelessWidget { ThemeData theme = Theme.of(context); ColorScheme colorScheme = theme.colorScheme; return Container( + // duration: const Duration(milliseconds: 100), alignment: alignment, margin: margin ?? const EdgeInsets.all(4), clipBehavior: Clip.hardEdge, decoration: getCardDecoration( context, color: cardColor, + isSelected: isSelected, showLightBorder: showLightBorder, showShadow: showShadow, elevationMultiplier: elevationMultiplier, @@ -84,6 +87,7 @@ class CardContainer extends StatelessWidget { : Material( color: Colors.transparent, child: InkWell( + onLongPress: onLongPress, onTap: onTap, splashColor: cardColor.darken(0.075), borderRadius: Theme.of(context).toggleButtonsTheme.borderRadius, diff --git a/lib/common/widgets/fields/select_field/select_bottom_sheet.dart b/lib/common/widgets/fields/select_field/select_bottom_sheet.dart index a19cdf19..c916c4e6 100644 --- a/lib/common/widgets/fields/select_field/select_bottom_sheet.dart +++ b/lib/common/widgets/fields/select_field/select_bottom_sheet.dart @@ -155,7 +155,7 @@ class SelectBottomSheet extends StatelessWidget { // // ], // ), - const SizedBox(height: 12.0), + // const SizedBox(height: 12.0), Flexible( child: _getOptionCard(), ), diff --git a/lib/common/widgets/fields/slider_field.dart b/lib/common/widgets/fields/slider_field.dart index ba242740..636d7170 100644 --- a/lib/common/widgets/fields/slider_field.dart +++ b/lib/common/widgets/fields/slider_field.dart @@ -11,9 +11,11 @@ class SliderField extends StatefulWidget { required this.max, required this.title, this.unit = '', - this.snapLength}); + this.snapLength, + this.description = ''}); final String title; + final String description; final double value; final double min; final double max; @@ -89,6 +91,10 @@ class _SliderFieldState extends State { widget.title, style: textTheme.headlineMedium, ), + if (widget.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(widget.description, style: textTheme.bodyMedium) + ], const SizedBox(height: 8.0), Row( children: [ @@ -97,7 +103,7 @@ class _SliderFieldState extends State { // height: textSize.height, // width: 50, child: Row( - // crossAxisAlignment: CrossAxisAlignment.end, + // crossAxisAlignment: CrossAxisAlignment.end, children: [ IntrinsicWidth( child: TextField( diff --git a/lib/common/widgets/fields/switch_field.dart b/lib/common/widgets/fields/switch_field.dart index f3d76b2c..89729337 100644 --- a/lib/common/widgets/fields/switch_field.dart +++ b/lib/common/widgets/fields/switch_field.dart @@ -2,13 +2,14 @@ import 'package:flutter/material.dart'; class SwitchField extends StatefulWidget { const SwitchField( - {Key? key, + {super.key, required this.value, required this.onChanged, - required this.name}) - : super(key: key); + required this.name, + this.description = ""}); final String name; + final String description; final bool value; final void Function(bool value)? onChanged; @@ -19,6 +20,9 @@ class SwitchField extends StatefulWidget { class _SwitchFieldState extends State { @override Widget build(BuildContext context) { + ThemeData theme = Theme.of(context); + TextTheme textTheme = theme.textTheme; + return Material( color: Colors.transparent, child: InkWell( @@ -29,9 +33,21 @@ class _SwitchFieldState extends State { children: [ Expanded( flex: 100, - child: Text( - widget.name, - style: Theme.of(context).textTheme.headlineMedium, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.name, + style: textTheme.headlineMedium, + ), + if (widget.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(widget.description, style: textTheme.bodyMedium) + ], + ], + ), ), ), const Spacer(), diff --git a/lib/common/widgets/list/animated_reorderable_list/animated_gridview.dart b/lib/common/widgets/list/animated_reorderable_list/animated_gridview.dart new file mode 100644 index 00000000..9c4c1ac1 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animated_gridview.dart @@ -0,0 +1,207 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'builder/motion_list_base.dart'; +import 'builder/motion_list_impl.dart'; + +/// enterTransition: [FadeEffect(), ScaleEffect()], +/// +/// Effects are always run in parallel (ie. the fade and scale effects in the +/// example above would be run simultaneously), but you can apply delays to +/// offset them or run them in sequence. +/// +/// A Flutter AnimatedGridView that animates insertion and removal of the item. +class AnimatedGridView extends StatelessWidget { + /// The current list of items that this[MotionGridViewBuilder] should represent. + final List items; + + ///Called, as needed, to build list item widget + final ItemBuilder itemBuilder; + + /// Controls the layout of tiles in a grid. + /// Given the current constraints on the grid, + /// a SliverGridDelegate computes the layout for the tiles in the grid. + /// The tiles can be placed arbitrarily, + /// but it is more efficient to place tiles in roughly in order by scroll offset because grids reify a contiguous sequence of children. + final SliverGridDelegate sliverGridDelegate; + + ///List of [AnimationEffect] used for the appearing animation when item is added in the list. + /// + ///Defaults to [FadeAnimation()] + final List? enterTransition; + + ///List of [AnimationEffect] used for the disappearing animation when item is removed from list. + /// + ///Defaults to [FadeAnimation()] + final List? exitTransition; + + /// The duration of the animation when an item was inserted into the list. + /// + /// If you provide a specific duration for each AnimationEffect, it will override this [insertDuration]. + final Duration? insertDuration; + + /// The duration of the animation when an item was removed from the list. + /// + /// If you provide a specific duration for each AnimationEffect, it will override this [removeDuration]. + final Duration? removeDuration; + + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + final Axis scrollDirection; + + /// {@template flutter.widgets.reorderable_list.padding} + /// The amount of space by which to inset the list contents. + /// + /// It defaults to `EdgeInsets.all(0)`. + /// {@endtemplate} + final EdgeInsetsGeometry? padding; + + /// {@template flutter.widgets.scroll_view.reverse} + /// Whether the scroll view scrolls in the reading direction. + /// + /// For example, if the reading direction is left-to-right and + /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from + /// left to right when [reverse] is false and from right to left when + /// [reverse] is true. + /// + /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + /// {@endtemplate} + final bool reverse; + + /// [ScrollController] to get the current scroll position. + /// + /// Must be null if [primary] is true. + /// + /// It can be used to read the current + // scroll position (see [ScrollController.offset]), or change it (see + // [ScrollController.animateTo]). + final ScrollController? controller; + + /// When this is true, the scroll view is scrollable even if it does not have + /// sufficient content to actually scroll. Otherwise, by default the user can + /// only scroll the view if it has sufficient content. See [physics]. + /// + /// Cannot be true while a [ScrollController] is provided to `controller`, + /// only one ScrollController can be associated with a ScrollView. + /// + /// Defaults to null. + final bool? primary; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. Furthermore, if [primary] is + /// false, then the user cannot scroll if there is insufficient content to + /// scroll, while if [primary] is true, they can always attempt to scroll. + final ScrollPhysics? physics; + + /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit + /// [ScrollPhysics] is provided in [physics], it will take precedence, + /// followed by [scrollBehavior], and then the inherited ancestor + /// [ScrollBehavior]. + final ScrollBehavior? scrollBehavior; + + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final String? restorationId; + + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// Defaults to [Clip.hardEdge]. + /// + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final Clip clipBehavior; + + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final DragStartBehavior dragStartBehavior; + + /// A custom builder that is for adding items with animations. + /// + /// The child argument is the widget that is returned by [itemBuilder], + /// and the `animation` is an [Animation] that should be used to animate an exit + /// transition for the widget that is built. + final AnimatedWidgetBuilder? insertItemBuilder; + + /// A custom builder that is for removing items with animations. + /// + /// The child argument is the widget that is returned by [itemBuilder], + /// and the `animation` is an [Animation] that should be used to animate an exit + /// transition for the widget that is built. + final AnimatedWidgetBuilder? removeItemBuilder; + + /// Whether the extent of the scroll view in the scrollDirection should be determined by the contents being viewed. + final bool shrinkWrap; + + /// A function that compares two items to determine whether they are the same. + final bool Function(E a, E b)? isSameItem; + + const AnimatedGridView( + {Key? key, + required this.items, + required this.itemBuilder, + required this.sliverGridDelegate, + this.enterTransition, + this.exitTransition, + this.insertDuration, + this.removeDuration, + this.padding, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.controller, + this.primary, + this.physics, + this.scrollBehavior, + this.restorationId, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.dragStartBehavior = DragStartBehavior.start, + this.clipBehavior = Clip.hardEdge, + this.insertItemBuilder, + this.removeItemBuilder, + this.shrinkWrap = false, + this.isSameItem}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return CustomScrollView( + scrollDirection: scrollDirection, + reverse: reverse, + controller: controller, + primary: primary, + physics: physics, + scrollBehavior: scrollBehavior, + restorationId: restorationId, + keyboardDismissBehavior: keyboardDismissBehavior, + dragStartBehavior: dragStartBehavior, + clipBehavior: clipBehavior, + shrinkWrap: shrinkWrap, + slivers: [ + SliverPadding( + padding: padding ?? EdgeInsets.zero, + sliver: MotionListImpl.grid( + items: items, + itemBuilder: itemBuilder, + sliverGridDelegate: sliverGridDelegate, + insertDuration: insertDuration, + removeDuration: removeDuration, + enterTransition: enterTransition, + exitTransition: exitTransition, + scrollDirection: scrollDirection, + insertItemBuilder: insertItemBuilder, + removeItemBuilder: removeItemBuilder, + isSameItem: isSameItem), + ), + ]); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animated_listview.dart b/lib/common/widgets/list/animated_reorderable_list/animated_listview.dart new file mode 100644 index 00000000..d304220e --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animated_listview.dart @@ -0,0 +1,200 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'builder/motion_list_base.dart'; +import 'builder/motion_list_impl.dart'; + +/// enterTransition: [FadeEffect(), ScaleEffect()], +/// +/// Effects are always run in parallel (ie. the fade and scale effects in the +/// example above would be run simultaneously), but you can apply delays to +/// offset them or run them in sequence. +/// A Flutter AnimatedGridView that animates insertion and removal of the item. +class AnimatedListView extends StatelessWidget { + /// The current list of items that this[MotionListViewBuilder] should represent. + final List items; + + ///Called, as needed, to build list item widget + final ItemBuilder itemBuilder; + + ///List of [AnimationEffect](s) used for the appearing animation when an item was inserted into the list. + /// + ///Defaults to [FadeAnimation()] + final List? enterTransition; + + ///List of [AnimationEffect](s) used for the disappearing animation when an item was removed from the list. + /// + ///Defaults to [FadeAnimation()] + final List? exitTransition; + + /// The duration of the animation when an item was inserted into the list. + /// + /// If you provide a specific duration for each AnimationEffect, it will override this [insertDuration]. + final Duration? insertDuration; + + /// The duration of the animation when an item was removed from the list. + /// + /// If you provide a specific duration for each AnimationEffect, it will override this [removeDuration]. + final Duration? removeDuration; + + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + final Axis scrollDirection; + + /// {@template flutter.widgets.scroll_view.reverse} + /// Whether the scroll view scrolls in the reading direction. + /// + /// For example, if the reading direction is left-to-right and + /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from + /// left to right when [reverse] is false and from right to left when + /// [reverse] is true. + /// + /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + /// {@endtemplate} + final bool reverse; + + /// [ScrollController] to get the current scroll position. + /// + /// Must be null if [primary] is true. + /// + /// It can be used to read the current + // scroll position (see [ScrollController.offset]), or change it (see + // [ScrollController.animateTo]). + final ScrollController? controller; + + /// When this is true, the scroll view is scrollable even if it does not have + /// sufficient content to actually scroll. Otherwise, by default the user can + /// only scroll the view if it has sufficient content. See [physics]. + /// + /// Cannot be true while a [ScrollController] is provided to `controller`, + /// only one ScrollController can be associated with a ScrollView. + /// + /// Defaults to null. + final bool? primary; + + /// {@template flutter.widgets.reorderable_list.padding} + /// The amount of space by which to inset the list contents. + /// + /// It defaults to `EdgeInsets.all(0)`. + /// {@endtemplate} + final EdgeInsetsGeometry? padding; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. Furthermore, if [primary] is + /// false, then the user cannot scroll if there is insufficient content to + /// scroll, while if [primary] is true, they can always attempt to scroll. + final ScrollPhysics? physics; + + /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit + /// [ScrollPhysics] is provided in [physics], it will take precedence, + /// followed by [scrollBehavior], and then the inherited ancestor + /// [ScrollBehavior]. + final ScrollBehavior? scrollBehavior; + + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final String? restorationId; + + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// Defaults to [Clip.hardEdge]. + /// + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final Clip clipBehavior; + + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final DragStartBehavior dragStartBehavior; + + /// A custom builder that is for adding items with animations. + /// + /// The `context` argument is the build context where the widget will be + /// created, the `index` is the index of the item to be built, and the + /// `animation` is an [Animation] that should be used to animate an entry + /// transition for the widget that is built. + final AnimatedWidgetBuilder? insertItemBuilder; + + /// A custom builder that is for removing items with animations. + /// + /// The `context` argument is the build context where the widget will be + /// created, the `index` is the index of the item to be built, and the + /// `animation` is an [Animation] that should be used to animate an exit + /// transition for the widget that is built. + final AnimatedWidgetBuilder? removeItemBuilder; + + /// Whether the extent of the scroll view in the scrollDirection should be determined by the contents being viewed. + final bool shrinkWrap; + + /// A function that compares two items to determine whether they are the same. + final bool Function(E a, E b)? isSameItem; + + const AnimatedListView({ + Key? key, + required this.items, + required this.itemBuilder, + this.enterTransition, + this.exitTransition, + this.insertDuration, + this.removeDuration, + this.scrollDirection = Axis.vertical, + this.padding, + this.reverse = false, + this.controller, + this.primary, + this.physics, + this.scrollBehavior, + this.restorationId, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.dragStartBehavior = DragStartBehavior.start, + this.clipBehavior = Clip.hardEdge, + this.insertItemBuilder, + this.removeItemBuilder, + this.shrinkWrap = false, + this.isSameItem, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return CustomScrollView( + scrollDirection: scrollDirection, + reverse: reverse, + controller: controller, + primary: primary, + physics: physics, + scrollBehavior: scrollBehavior, + restorationId: restorationId, + keyboardDismissBehavior: keyboardDismissBehavior, + dragStartBehavior: dragStartBehavior, + clipBehavior: clipBehavior, + shrinkWrap: shrinkWrap, + slivers: [ + SliverPadding( + padding: padding ?? EdgeInsets.zero, + sliver: MotionListImpl( + items: items, + itemBuilder: itemBuilder, + enterTransition: enterTransition, + exitTransition: exitTransition, + insertDuration: insertDuration, + removeDuration: removeDuration, + scrollDirection: scrollDirection, + insertItemBuilder: insertItemBuilder, + removeItemBuilder: removeItemBuilder, + isSameItem: isSameItem, + ), + ), + ]); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animated_reorderable_gridview.dart b/lib/common/widgets/list/animated_reorderable_list/animated_reorderable_gridview.dart new file mode 100644 index 00000000..98ebc732 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animated_reorderable_gridview.dart @@ -0,0 +1,257 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; + +import 'builder/motion_list_base.dart'; +import 'builder/motion_list_impl.dart'; + +///A GridView that enables users to interactively reorder items through dragging, with animated insertion and removal of items. +/// +/// enterTransition: [FadeEffect(), ScaleEffect()], +/// +/// Effects are always run in parallel (ie. the fade and scale effects in the +/// example above would be run simultaneously), but you can apply delays to +/// offset them or run them in sequence. +/// +/// The [onReorder] parameter is required and will be called when a child +/// widget is dragged to a new position. +/// +/// +/// All list items must have a key. +/// +/// While a drag is underway, the widget returned by the [AnimatedReorderableGridView.proxyDecorator] +/// callback serves as a "proxy" (a substitute) for the item in the list. The proxy is +/// created with the original list item as its child. +class AnimatedReorderableGridView extends StatelessWidget { + /// The current list of items that this[AnimatedReorderableGridView] should represent. + final List items; + + ///Called, as needed, to build list item widget + final ItemBuilder itemBuilder; + + /// Controls the layout of tiles in a grid. + /// Given the current constraints on the grid, + /// a SliverGridDelegate computes the layout for the tiles in the grid. + /// The tiles can be placed arbitrarily, + /// but it is more efficient to place tiles in roughly in order by scroll offset because grids reify a contiguous sequence of children. + final SliverGridDelegate sliverGridDelegate; + + ///List of [AnimationEffect](s) used for the appearing animation when item is added in the list. + /// + ///Defaults to [FadeAnimation()] + final List? enterTransition; + + ///List of [AnimationEffect](s) used for the disappearing animation when item is removed from list. + /// + ///Defaults to [FadeAnimation()] + final List? exitTransition; + + /// The duration of the animation when an item was inserted into the list. + /// + /// If you provide a specific duration for each AnimationEffect, it will override this [insertDuration]. + final Duration? insertDuration; + + /// The duration of the animation when an item was removed from the list. + /// + /// If you provide a specific duration for each AnimationEffect, it will override this [removeDuration]. + final Duration? removeDuration; + + /// A callback used by [ReorderableList] to report that a list item has moved + /// to a new position in the list. + /// + /// Implementations should remove the corresponding list item at [oldIndex] + /// and reinsert it at [newIndex]. + final ReorderCallback onReorder; + + /// A callback that is called when an item drag has started. + /// + /// The index parameter of the callback is the index of the selected item. + final void Function(int)? onReorderStart; + + /// A callback that is called when the dragged item is dropped. + /// + /// The index parameter of the callback is the index where the item is + /// dropped. Unlike [onReorder], this is called even when the list item is + /// dropped in the same location. + final void Function(int)? onReorderEnd; + + /// {@template flutter.widgets.reorderable_list.proxyDecorator} + /// A callback that allows the app to add an animated decoration around + /// an item when it is being dragged. + /// {@endtemplate} + final ReorderItemProxyDecorator? proxyDecorator; + + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + final Axis scrollDirection; + + /// {@template flutter.widgets.reorderable_list.padding} + /// The amount of space by which to inset the list contents. + /// + /// It defaults to `EdgeInsets.all(0)`. + /// {@endtemplate} + final EdgeInsetsGeometry? padding; + + /// {@template flutter.widgets.scroll_view.reverse} + /// Whether the scroll view scrolls in the reading direction. + /// + /// For example, if the reading direction is left-to-right and + /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from + /// left to right when [reverse] is false and from right to left when + /// [reverse] is true. + /// + /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + /// {@endtemplate} + final bool reverse; + + /// [ScrollController] to get the current scroll position. + /// + /// Must be null if [primary] is true. + /// + /// It can be used to read the current + // scroll position (see [ScrollController.offset]), or change it (see + // [ScrollController.animateTo]). + final ScrollController? controller; + + /// When this is true, the scroll view is scrollable even if it does not have + /// sufficient content to actually scroll. Otherwise, by default the user can + /// only scroll the view if it has sufficient content. See [physics]. + /// + /// Cannot be true while a [ScrollController] is provided to `controller`, + /// only one ScrollController can be associated with a ScrollView. + /// + /// Defaults to null. + final bool? primary; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. Furthermore, if [primary] is + /// false, then the user cannot scroll if there is insufficient content to + /// scroll, while if [primary] is true, they can always attempt to scroll. + final ScrollPhysics? physics; + + /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit + /// [ScrollPhysics] is provided in [physics], it will take precedence, + /// followed by [scrollBehavior], and then the inherited ancestor + /// [ScrollBehavior]. + final ScrollBehavior? scrollBehavior; + + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final String? restorationId; + + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// Defaults to [Clip.hardEdge]. + /// + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final Clip clipBehavior; + + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final DragStartBehavior dragStartBehavior; + + /// A custom builder that is for adding items with animations. + /// + /// The child argument is the widget that is returned by [itemBuilder], + /// and the `animation` is an [Animation] that should be used to animate an exit + /// transition for the widget that is built. + final AnimatedWidgetBuilder? insertItemBuilder; + + /// A custom builder that is for removing items with animations. + /// + /// The child argument is the widget that is returned by [itemBuilder], + /// and the `animation` is an [Animation] that should be used to animate an exit + /// transition for the widget that is built. + final AnimatedWidgetBuilder? removeItemBuilder; + + /// Whether the items can be dragged by long pressing on them. + final bool longPressDraggable; + + /// Whether the extent of the scroll view in the scrollDirection should be determined by the contents being viewed. + final bool shrinkWrap; + + /// A function that compares two items to determine whether they are the same. + final bool Function(E a, E b)? isSameItem; + + const AnimatedReorderableGridView( + {Key? key, + required this.items, + required this.itemBuilder, + required this.sliverGridDelegate, + required this.onReorder, + this.enterTransition, + this.exitTransition, + this.insertDuration, + this.removeDuration, + this.onReorderStart, + this.onReorderEnd, + this.proxyDecorator, + this.padding, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.controller, + this.primary, + this.physics, + this.scrollBehavior, + this.restorationId, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.dragStartBehavior = DragStartBehavior.start, + this.clipBehavior = Clip.hardEdge, + this.longPressDraggable = true, + this.shrinkWrap = false, + this.insertItemBuilder, + this.removeItemBuilder, + this.isSameItem}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return CustomScrollView( + scrollDirection: scrollDirection, + reverse: reverse, + controller: controller, + primary: primary, + physics: physics, + scrollBehavior: scrollBehavior, + restorationId: restorationId, + keyboardDismissBehavior: keyboardDismissBehavior, + dragStartBehavior: dragStartBehavior, + clipBehavior: clipBehavior, + shrinkWrap: shrinkWrap, + slivers: [ + SliverPadding( + padding: padding ?? EdgeInsets.zero, + sliver: MotionListImpl.grid( + items: items, + itemBuilder: itemBuilder, + sliverGridDelegate: sliverGridDelegate, + insertDuration: insertDuration, + removeDuration: removeDuration, + enterTransition: enterTransition, + exitTransition: exitTransition, + onReorder: onReorder, + onReorderStart: onReorderStart, + onReorderEnd: onReorderEnd, + proxyDecorator: proxyDecorator, + scrollDirection: scrollDirection, + insertItemBuilder: insertItemBuilder, + removeItemBuilder: removeItemBuilder, + longPressDraggable: longPressDraggable, + isSameItem: isSameItem, + ), + ), + ]); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animated_reorderable_listview.dart b/lib/common/widgets/list/animated_reorderable_list/animated_reorderable_listview.dart new file mode 100644 index 00000000..483b5c82 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animated_reorderable_listview.dart @@ -0,0 +1,273 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'builder/motion_list_base.dart'; +import 'builder/motion_list_impl.dart'; + +///A [ListView] that enables users to interactively reorder items through dragging, with animated insertion and removal of items. +/// +/// enterTransition: [FadeEffect(), ScaleEffect()], +/// +/// Effects are always run in parallel (ie. the fade and scale effects in the +/// example above would be run simultaneously), but you can apply delays to +/// offset them or run them in sequence. +/// +/// The [onReorder] parameter is required and will be called when a child +/// widget is dragged to a new position. +/// +/// By default, on [TargetPlatformVariant.desktop] platforms each item will +/// have a drag handle added on top of it that will allow the user to grab it +/// to move the item. On [TargetPlatformVariant.mobile], no drag handle will be +/// added, but when the user long presses anywhere on the item it will start +/// moving the item.Displaying drag handles can be controlled with [AnimatedReorderableListView.buildDefaultDragHandles]. +/// +/// All list items must have a key. +/// +/// While a drag is underway, the widget returned by the [AnimatedReorderableGridView.proxyDecorator] +/// callback serves as a "proxy" (a substitute) for the item in the list. The proxy is +/// created with the original list item as its child. + +class AnimatedReorderableListView extends StatelessWidget { + /// The current list of items that this[AnimatedReorderableListView] should represent. + final List items; + + ///Called, as needed, to build list item widget + final ItemBuilder itemBuilder; + + ///List of [AnimationEffect](s) used for the appearing animation when an item was inserted into the list. + /// + ///Defaults to [FadeAnimation()] + final List? enterTransition; + + ///List of [AnimationEffect](s) used for the disappearing animation when an item was removed from the list. + /// + ///Defaults to [FadeAnimation()] + final List? exitTransition; + + /// The duration of the animation when an item was inserted into the list. + /// + /// If you provide a specific duration for each AnimationEffect, it will override this [insertDuration]. + final Duration? insertDuration; + + /// The duration of the animation when an item was removed from the list. + /// + /// If you provide a specific duration for each AnimationEffect, it will override this [removeDuration]. + final Duration? removeDuration; + + /// A callback used by [ReorderableList] to report that a list item has moved + /// to a new position in the list. + /// + /// Implementations should remove the corresponding list item at [oldIndex] + /// and reinsert it at [newIndex]. + final ReorderCallback onReorder; + + /// A callback that is called when an item drag has started. + /// + /// The index parameter of the callback is the index of the selected item. + final void Function(int)? onReorderStart; + + /// A callback that is called when the dragged item is dropped. + /// + /// The index parameter of the callback is the index where the item is + /// dropped. Unlike [onReorder], this is called even when the list item is + /// dropped in the same location. + final void Function(int)? onReorderEnd; + + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + final Axis scrollDirection; + + /// {@template flutter.widgets.reorderable_list.proxyDecorator} + /// A callback that allows the app to add an animated decoration around + /// an item when it is being dragged. + /// {@endtemplate} + final ReorderItemProxyDecorator? proxyDecorator; + + /// If true, on desktop platforms, a drag handle is stacked over the center of each item's trailing edge; + /// on mobile platforms, a long press anywhere on the item starts a drag. + /// + /// The default desktop drag handle is just an [Icons.drag_handle] wrapped by [ReorderableDragStartListener]. + /// On mobile platforms, the entire item is wrapped with a [ReorderableDragStartListener]. + /// + /// To change the appearance or the layout of the drag handles, make this parameter false + /// and wrap each list item, or a widget within each list item, with [ReorderableDragStartListener]or + /// a subclass of [ReorderableDragStartListener]. + /// + /// To get the idea [Flutter Example](https://api.flutter.dev/flutter/material/ReorderableListView/buildDefaultDragHandles.html) + + final bool buildDefaultDragHandles; + + /// {@template flutter.widgets.scroll_view.reverse} + /// Whether the scroll view scrolls in the reading direction. + /// + /// For example, if the reading direction is left-to-right and + /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from + /// left to right when [reverse] is false and from right to left when + /// [reverse] is true. + /// + /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + /// {@endtemplate} + final bool reverse; + + /// [ScrollController] to get the current scroll position. + /// + /// Must be null if [primary] is true. + /// + /// It can be used to read the current + // scroll position (see [ScrollController.offset]), or change it (see + // [ScrollController.animateTo]). + final ScrollController? controller; + + /// When this is true, the scroll view is scrollable even if it does not have + /// sufficient content to actually scroll. Otherwise, by default the user can + /// only scroll the view if it has sufficient content. See [physics]. + /// + /// Cannot be true while a [ScrollController] is provided to `controller`, + /// only one ScrollController can be associated with a ScrollView. + /// + /// Defaults to null. + final bool? primary; + + /// {@template flutter.widgets.reorderable_list.padding} + /// The amount of space by which to inset the list contents. + /// + /// It defaults to `EdgeInsets.all(0)`. + /// {@endtemplate} + final EdgeInsetsGeometry? padding; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. Furthermore, if [primary] is + /// false, then the user cannot scroll if there is insufficient content to + /// scroll, while if [primary] is true, they can always attempt to scroll. + final ScrollPhysics? physics; + + /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit + /// [ScrollPhysics] is provided in [physics], it will take precedence, + /// followed by [scrollBehavior], and then the inherited ancestor + /// [ScrollBehavior]. + final ScrollBehavior? scrollBehavior; + + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final String? restorationId; + + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// Defaults to [Clip.hardEdge]. + /// + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final Clip clipBehavior; + + /// Creates a ScrollView that creates custom scroll effects using slivers. + /// See the ScrollView constructor for more details on these arguments. + final DragStartBehavior dragStartBehavior; + + /// A custom builder that is for adding items with animations. + /// + /// The child argument is the widget that is returned by [itemBuilder], + /// and the `animation` is an [Animation] that should be used to animate an exit + /// transition for the widget that is built. + final AnimatedWidgetBuilder? insertItemBuilder; + + /// A custom builder that is for removing items with animations. + /// + /// The child argument is the widget that is returned by [itemBuilder], + /// and the `animation` is an [Animation] that should be used to animate an exit + /// transition for the widget that is built. + final AnimatedWidgetBuilder? removeItemBuilder; + + /// Whether the items can be dragged by long pressing on them. + final bool longPressDraggable; + + final bool useDefaultDragListeners; + + /// Whether the extent of the scroll view in the scrollDirection should be determined by the contents being viewed. + final bool shrinkWrap; + + /// A function that compares two items to determine whether they are the same. + final bool Function(E a, E b)? isSameItem; + + const AnimatedReorderableListView({ + Key? key, + required this.items, + required this.itemBuilder, + required this.onReorder, + this.enterTransition, + this.exitTransition, + this.insertDuration, + this.removeDuration, + this.onReorderStart, + this.onReorderEnd, + this.proxyDecorator, + this.scrollDirection = Axis.vertical, + this.padding, + this.reverse = false, + this.controller, + this.primary, + this.physics, + this.scrollBehavior, + this.restorationId, + this.buildDefaultDragHandles = true, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.dragStartBehavior = DragStartBehavior.start, + this.clipBehavior = Clip.hardEdge, + this.insertItemBuilder, + this.removeItemBuilder, + this.longPressDraggable = true, + this.useDefaultDragListeners = true, + this.shrinkWrap = false, + this.isSameItem, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return CustomScrollView( + scrollDirection: scrollDirection, + reverse: reverse, + controller: controller, + primary: primary, + physics: physics, + scrollBehavior: scrollBehavior, + restorationId: restorationId, + keyboardDismissBehavior: keyboardDismissBehavior, + dragStartBehavior: dragStartBehavior, + clipBehavior: clipBehavior, + shrinkWrap: shrinkWrap, + slivers: [ + SliverPadding( + padding: padding ?? EdgeInsets.zero, + sliver: MotionListImpl( + items: items, + itemBuilder: itemBuilder, + enterTransition: enterTransition, + exitTransition: exitTransition, + insertDuration: insertDuration, + removeDuration: removeDuration, + onReorder: onReorder, + onReorderStart: onReorderStart, + onReorderEnd: onReorderEnd, + proxyDecorator: proxyDecorator, + buildDefaultDragHandles: buildDefaultDragHandles, + scrollDirection: scrollDirection, + insertItemBuilder: insertItemBuilder, + removeItemBuilder: removeItemBuilder, + longPressDraggable: longPressDraggable, + useDefaultDragListeners: useDefaultDragListeners, + isSameItem: isSameItem, + ), + ), + ]); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/animation.dart b/lib/common/widgets/list/animated_reorderable_list/animation/animation.dart new file mode 100644 index 00000000..5c50b12f --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/animation.dart @@ -0,0 +1,14 @@ +export 'fade_in.dart'; +export 'flipin_x.dart'; +export 'flipin_y.dart'; +export 'landing.dart'; +export 'scale_in.dart'; +export 'scale_in_bottom.dart'; +export 'scale_in_left.dart'; +export 'scale_in_right.dart'; +export 'scale_in_top.dart'; +export 'slide_in_down.dart'; +export 'slide_in_left.dart'; +export 'slide_in_right.dart'; +export 'slide_in_up.dart'; +export 'size_animation.dart'; diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/fade_in.dart b/lib/common/widgets/list/animated_reorderable_list/animation/fade_in.dart new file mode 100644 index 00000000..d295db44 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/fade_in.dart @@ -0,0 +1,23 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class FadeIn extends AnimationEffect { + static const double beginValue = 0.0; + static const double endValue = 1.0; + final double? begin; + final double? end; + + FadeIn({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation opacity = buildAnimation( + entry, + begin: begin ?? beginValue, + end: end ?? endValue, + totalDuration) + .animate(animation); + return FadeTransition(opacity: opacity, child: child); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/flipin_x.dart b/lib/common/widgets/list/animated_reorderable_list/animation/flipin_x.dart new file mode 100644 index 00000000..51361c08 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/flipin_x.dart @@ -0,0 +1,31 @@ +import 'dart:math'; + +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class FlipInX extends AnimationEffect { + static const double beginValue = pi / 2; + static const double endValue = 0.0; + final double? begin; + final double? end; + + FlipInX({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation rotation = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: endValue) + .animate(animation); + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return Transform( + transform: Matrix4.rotationX(rotation.value), + alignment: Alignment.center, + child: child, + ); + }, + child: child); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/flipin_y.dart b/lib/common/widgets/list/animated_reorderable_list/animation/flipin_y.dart new file mode 100644 index 00000000..42844956 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/flipin_y.dart @@ -0,0 +1,32 @@ +import 'dart:math'; + +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class FlipInY extends AnimationEffect { + static const double beginValue = pi / 2; + static const double endValue = 0.0; + final double? begin; + final double? end; + + FlipInY({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation rotation = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: endValue) + .animate(animation); + return AnimatedBuilder( + animation: rotation, + builder: (BuildContext context, Widget? child) { + return Transform( + transform: Matrix4.rotationY(rotation.value), + alignment: Alignment.center, + child: child, + ); + }, + child: child, + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/landing.dart b/lib/common/widgets/list/animated_reorderable_list/animation/landing.dart new file mode 100644 index 00000000..d2f2c777 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/landing.dart @@ -0,0 +1,26 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class Landing extends AnimationEffect { + static const double beginValue = 1.5; + static const double endValue = 1.0; + final double? begin; + final double? end; + + Landing({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation scale = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: end ?? endValue) + .animate(animation); + return FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: scale, + child: child, + ), + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart b/lib/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart new file mode 100644 index 00000000..0e177938 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart @@ -0,0 +1,75 @@ +import 'package:flutter/cupertino.dart'; + +abstract class AnimationEffect { + /// The delay for this specific [AnimationEffect]. + final Duration? delay; + + /// The duration for the specific [AnimationEffect]. + final Duration? duration; + + /// The curve for the specific [AnimationEffect]. + final Curve? curve; + + AnimationEffect({ + this.delay = Duration.zero, + this.duration = const Duration(milliseconds: 300), + this.curve, + }); + + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + return child; + } + + Animatable buildAnimation(EffectEntry entry, Duration totalDuration, + {required T begin, required T end}) { + return Tween(begin: begin, end: end) + .chain(entry.buildAnimation(totalDuration: totalDuration)); + } +} + +@immutable +class EffectEntry { + const EffectEntry({ + required this.animationEffect, + required this.delay, + required this.duration, + required this.curve, + }); + + /// The delay for this entry. + final Duration delay; + + /// The duration for this entry. + final Duration duration; + + /// The curve used by this entry. + final Curve curve; + + /// The effect associated with this entry. + final AnimationEffect animationEffect; + + /// The begin time for this entry. + Duration get begin => delay; + + /// The end time for this entry. + Duration get end => begin + duration; + + /// Builds a sub-animation based on the properties of this entry. + CurveTween buildAnimation({ + required Duration totalDuration, + Curve? curve, + }) { + int beginT = begin.inMicroseconds, endT = end.inMicroseconds; + return CurveTween( + curve: Interval(beginT / totalDuration.inMicroseconds, + endT / totalDuration.inMicroseconds, + curve: curve ?? this.curve), + ); + } + + @override + String toString() { + return "delay: $delay, Duration: $duration, curve: $curve, begin: $begin, end: $end, Effect: $animationEffect"; + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/provider/animation_type.dart b/lib/common/widgets/list/animated_reorderable_list/animation/provider/animation_type.dart new file mode 100644 index 00000000..339f93cb --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/provider/animation_type.dart @@ -0,0 +1,16 @@ +enum AnimationType { + fadeIn, + flipInY, + flipInX, + landing, + size, + scaleIn, + scaleInTop, + scaleInBottom, + scaleInLeft, + scaleInRight, + slideInLeft, + slideInRight, + slideInDown, + slideInUp, +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/scale_in.dart b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in.dart new file mode 100644 index 00000000..7a158ac2 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in.dart @@ -0,0 +1,23 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class ScaleIn extends AnimationEffect { + static const double beginValue = 0.0; + static const double endValue = 1.0; + final double? begin; + final double? end; + + ScaleIn({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation scale = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: endValue) + .animate(animation); + return ScaleTransition( + scale: scale, + child: child, + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_bottom.dart b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_bottom.dart new file mode 100644 index 00000000..844a2bcf --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_bottom.dart @@ -0,0 +1,25 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class ScaleInBottom extends AnimationEffect { + static const double beginValue = 0.0; + static const double endValue = 1.0; + final double? begin; + final double? end; + + ScaleInBottom( + {super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation scale = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: endValue) + .animate(animation); + return ScaleTransition( + alignment: Alignment.bottomCenter, + scale: scale, + child: child, + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_left.dart b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_left.dart new file mode 100644 index 00000000..ed8c35d1 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_left.dart @@ -0,0 +1,24 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class ScaleInLeft extends AnimationEffect { + static const double beginValue = 0.0; + static const double endValue = 1.0; + final double? begin; + final double? end; + + ScaleInLeft({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation scale = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: endValue) + .animate(animation); + return ScaleTransition( + alignment: Alignment.centerLeft, + scale: scale, + child: child, + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_right.dart b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_right.dart new file mode 100644 index 00000000..ea322822 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_right.dart @@ -0,0 +1,25 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class ScaleInRight extends AnimationEffect { + static const double beginValue = 0.0; + static const double endValue = 1.0; + final double? begin; + final double? end; + + ScaleInRight( + {super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation scale = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: endValue) + .animate(animation); + return ScaleTransition( + alignment: Alignment.centerRight, + scale: scale, + child: child, + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_top.dart b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_top.dart new file mode 100644 index 00000000..77588d9a --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/scale_in_top.dart @@ -0,0 +1,25 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + + +class ScaleInTop extends AnimationEffect { + static const double beginValue = 0.0; + static const double endValue = 1.0; + final double? begin; + final double? end; + + ScaleInTop({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation scale = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: endValue) + .animate(animation); + return ScaleTransition( + alignment: Alignment.topCenter, + scale: scale, + child: child, + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/size_animation.dart b/lib/common/widgets/list/animated_reorderable_list/animation/size_animation.dart new file mode 100644 index 00000000..62a3df0e --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/size_animation.dart @@ -0,0 +1,37 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class SizeAnimation extends AnimationEffect { + static const double beginValue = 0.0; + static const double endValue = 1.0; + static const double alignmentValue = 0.0; + final double? begin; + final double? end; + final Axis? axis; + final double? axisAlignment; + + SizeAnimation( + {super.delay, + super.duration, + super.curve, + this.begin, + this.end, + this.axis, + this.axisAlignment}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation sizeFactor = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: end ?? endValue) + .animate(animation); + return Align( + child: SizeTransition( + sizeFactor: sizeFactor, + axis: axis ?? Axis.horizontal, + axisAlignment: axisAlignment ?? alignmentValue, + child: child, + ), + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_down.dart b/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_down.dart new file mode 100644 index 00000000..3ebe8636 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_down.dart @@ -0,0 +1,26 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class SlideInDown extends AnimationEffect { + static const Offset beginValue = Offset(0, 1); + static const Offset endValue = Offset(0, 0); + final Offset? begin; + final Offset? end; + + SlideInDown({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation position = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: endValue) + .animate(animation); + return ClipRect( + clipBehavior: Clip.hardEdge, + child: SlideTransition( + position: position, + child: child, + ), + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_left.dart b/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_left.dart new file mode 100644 index 00000000..7f21192a --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_left.dart @@ -0,0 +1,22 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class SlideInLeft extends AnimationEffect { + static const Offset beginValue = Offset(-1, 0); + static const Offset endValue = Offset(0, 0); + final Offset? begin; + final Offset? end; + + SlideInLeft({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation position = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: end ?? endValue) + .animate(animation); + return ClipRect( + clipBehavior: Clip.hardEdge, + child: SlideTransition(position: position, child: child)); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_right.dart b/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_right.dart new file mode 100644 index 00000000..a6af2f25 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_right.dart @@ -0,0 +1,23 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class SlideInRight extends AnimationEffect { + static const Offset beginValue = Offset(1, 0); + static const Offset endValue = Offset(0, 0); + final Offset? begin; + final Offset? end; + + SlideInRight( + {super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation position = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: end ?? endValue) + .animate(animation); + return ClipRect( + clipBehavior: Clip.hardEdge, + child: SlideTransition(position: position, child: child)); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_up.dart b/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_up.dart new file mode 100644 index 00000000..cafb2d93 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/animation/slide_in_up.dart @@ -0,0 +1,22 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; + +class SlideInUp extends AnimationEffect { + static const Offset beginValue = Offset(0, -1); + static const Offset endValue = Offset(0, 0); + final Offset? begin; + final Offset? end; + + SlideInUp({super.delay, super.duration, super.curve, this.begin, this.end}); + + @override + Widget build(BuildContext context, Widget child, Animation animation, + EffectEntry entry, Duration totalDuration) { + final Animation position = buildAnimation(entry, totalDuration, + begin: begin ?? beginValue, end: end ?? endValue) + .animate(animation); + return ClipRect( + clipBehavior: Clip.hardEdge, + child: SlideTransition(position: position, child: child)); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/builder/motion_animated_builder.dart b/lib/common/widgets/list/animated_reorderable_list/builder/motion_animated_builder.dart new file mode 100644 index 00000000..65bd1d39 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/builder/motion_animated_builder.dart @@ -0,0 +1,903 @@ +import 'dart:math'; + +import 'package:clock_app/common/widgets/list/animated_reorderable_list/component/sliver_grid_with_fixed_cross_axis_count.dart'; +import 'package:clock_app/common/widgets/list/animated_reorderable_list/component/sliver_grid_with_main_axis_extent.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import '../component/drag_listener.dart'; +import '../model/motion_data.dart'; +import 'motion_list_base.dart'; + +part '../component/drag_item.dart'; + +part '../component/motion_animated_content.dart'; + +typedef CustomAnimatedWidgetBuilder = Widget Function( + BuildContext context, Widget child, Animation animation); + +class MotionBuilder extends StatefulWidget { + final CustomAnimatedWidgetBuilder insertAnimationBuilder; + final CustomAnimatedWidgetBuilder removeAnimationBuilder; + final ReorderCallback? onReorder; + final void Function(int index)? onReorderStart; + final void Function(int index)? onReorderEnd; + + final ReorderItemProxyDecorator? proxyDecorator; + final ItemBuilder itemBuilder; + final int initialCount; + final Axis scrollDirection; + final SliverGridDelegate? delegateBuilder; + final bool buildDefaultDragHandles; + final bool longPressDraggable; + final bool useDefaultDragListeners; + + const MotionBuilder( + {Key? key, + required this.itemBuilder, + required this.insertAnimationBuilder, + required this.removeAnimationBuilder, + this.onReorder, + this.onReorderEnd, + this.onReorderStart, + this.proxyDecorator, + this.initialCount = 0, + this.delegateBuilder, + this.scrollDirection = Axis.vertical, + required this.buildDefaultDragHandles, + required this.useDefaultDragListeners, + this.longPressDraggable = false}) + : assert(initialCount >= 0), + super(key: key); + + @override + State createState() => MotionBuilderState(); + + static MotionBuilderState of(BuildContext context) { + final MotionBuilderState? result = + context.findAncestorStateOfType(); + assert(() { + if (result == null) { + throw FlutterError( + 'MotionBuilderState.of() called with a context that does not contain a MotionBuilderState.\n' + 'No MotionBuilderState ancestor could be found starting from the ' + 'context that was passed to MotionBuilderState.of(). This can ' + 'happen when the context provided is from the same StatefulWidget that ' + 'built the AnimatedList.' + 'The context used was:\n' + ' $context', + ); + } + return true; + }()); + return result!; + } + + static MotionBuilderState? maybeOf(BuildContext context) { + return context.findAncestorStateOfType(); + } +} + +class MotionBuilderState extends State + with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + final List<_ActiveItem> _incomingItems = <_ActiveItem>[]; + final List<_ActiveItem> _outgoingItems = <_ActiveItem>[]; + int _itemsCount = 0; + + Map childrenMap = {}; + final Map _items = + {}; + + OverlayEntry? _overlayEntry; + int? _dragIndex; + _DragInfo? _dragInfo; + int? _insertIndex; + Offset? _finalDropPosition; + MultiDragGestureRecognizer? _recognizer; + int? _recognizerPointer; + EdgeDraggingAutoScroller? _autoScroller; + late ScrollableState _scrollable; + + bool autoScrolling = false; + + Axis get scrollDirection => axisDirectionToAxis(_scrollable.axisDirection); + + bool get _reverse => + _scrollable.axisDirection == AxisDirection.up || + _scrollable.axisDirection == AxisDirection.left; + + bool get isGrid => widget.delegateBuilder != null; + + @override + bool get wantKeepAlive => false; + + @override + void initState() { + _itemsCount = widget.initialCount; + for (int i = 0; i < widget.initialCount; i++) { + childrenMap[i] = MotionData(); + } + + super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scrollable = Scrollable.of(context); + } + + @override + void didUpdateWidget(covariant MotionBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialCount != oldWidget.initialCount) { + cancelReorder(); + } + } + + void startItemDragReorder( + {required int index, + required PointerDownEvent event, + required MultiDragGestureRecognizer recognizer}) { + assert(0 <= index && index < _itemsCount); + setState(() { + if (_dragInfo != null) { + cancelReorder(); + } else if (_recognizer != null && _recognizerPointer != event.pointer) { + _recognizer!.dispose(); + _recognizer = null; + _recognizerPointer = null; + } + if (_items.containsKey(index)) { + _dragIndex = index; + _recognizer = recognizer + ..onStart = _dragStart + ..addPointer(event); + _recognizerPointer = event.pointer; + } else { + throw Exception("Attempting ro start drag on a non-visible item"); + } + }); + } + + Drag? _dragStart(Offset position) { + assert(_dragInfo == null); + final MotionAnimatedContentState item = _items[_dragIndex]!; + item.dragging = true; + widget.onReorderStart?.call(_dragIndex!); + item.rebuild(); + _insertIndex = item.index; + _dragInfo = _DragInfo( + item: item, + initialPosition: position, + scrollDirection: scrollDirection, + gridView: isGrid, + onUpdate: _dragUpdate, + onCancel: _dragCancel, + onEnd: _dragEnd, + onDragCompleted: _dropCompleted, + proxyDecorator: widget.proxyDecorator, + tickerProvider: this); + + _dragInfo!.startDrag(); + item.dragSize = _dragInfo!.itemSize; + + final OverlayState overlay = Overlay.of(context, debugRequiredFor: widget); + assert(_overlayEntry == null); + _overlayEntry = OverlayEntry(builder: _dragInfo!.createProxy); + overlay.insert(_overlayEntry!); + + for (final MotionAnimatedContentState childItem in _items.values) { + if (childItem == item || !childItem.mounted) { + continue; + } + item.updateForGap(false); + } + return _dragInfo; + } + + void _dragUpdate(_DragInfo item, Offset position, Offset delta) { + setState(() { + _overlayEntry?.markNeedsBuild(); + _dragUpdateItems(); + _autoScrollIfNecessary(); + }); + } + + void _dragCancel(_DragInfo item) { + setState(() { + _dragReset(); + }); + } + + Future _autoScrollIfNecessary() async { + if (autoScrolling || _dragInfo == null || _dragInfo!.scrollable == null) { + return; + } + + final position = _dragInfo!.scrollable!.position; + double? newOffset; + + const duration = Duration(milliseconds: 14); + const step = 1.0; + const overDragMax = 20.0; + const overDragCoef = 10; + + final isVertical = widget.scrollDirection == Axis.vertical; + + /// get the scroll window position on the screen + final scrollRenderBox = + _dragInfo!.scrollable!.context.findRenderObject()! as RenderBox; + final Offset scrollPosition = scrollRenderBox.localToGlobal(Offset.zero); + + /// calculate the start and end position for the scroll window + double scrollWindowStart = + isVertical ? scrollPosition.dy : scrollPosition.dx; + double scrollWindowEnd = scrollWindowStart + + (isVertical ? scrollRenderBox.size.height : scrollRenderBox.size.width); + + /// get the proxy (dragged) object's position on the screen + final proxyObjectPosition = _dragInfo!.dragPosition - _dragInfo!.dragOffset; + + /// calculate the start and end position for the proxy object + double proxyObjectStart = + isVertical ? proxyObjectPosition.dy : proxyObjectPosition.dx; + double proxyObjectEnd = proxyObjectStart + + (isVertical ? _dragInfo!.itemSize.height : _dragInfo!.itemSize.width); + + if (!_reverse) { + /// if start of proxy object is before scroll window + if (proxyObjectStart < scrollWindowStart && + position.pixels > position.minScrollExtent) { + final overDrag = max(scrollWindowStart - proxyObjectStart, overDragMax); + newOffset = max(position.minScrollExtent, + position.pixels - step * overDrag / overDragCoef); + } + + /// if end of proxy object is after scroll window + else if (proxyObjectEnd > scrollWindowEnd && + position.pixels < position.maxScrollExtent) { + final overDrag = max(proxyObjectEnd - scrollWindowEnd, overDragMax); + newOffset = min(position.maxScrollExtent, + position.pixels + step * overDrag / overDragCoef); + } + } else { + /// if start of proxy object is before scroll window + if (proxyObjectStart < scrollWindowStart && + position.pixels < position.maxScrollExtent) { + final overDrag = max(scrollWindowStart - proxyObjectStart, overDragMax); + newOffset = max(position.minScrollExtent, + position.pixels + step * overDrag / overDragCoef); + } + + /// if end of proxy object is after scroll window + else if (proxyObjectEnd > scrollWindowEnd && + position.pixels > position.minScrollExtent) { + final overDrag = max(proxyObjectEnd - scrollWindowEnd, overDragMax); + newOffset = min(position.maxScrollExtent, + position.pixels - step * overDrag / overDragCoef); + } + } + + if (newOffset != null && (newOffset - position.pixels).abs() >= 1.0) { + autoScrolling = true; + await position.animateTo( + newOffset, + duration: duration, + curve: Curves.linear, + ); + autoScrolling = false; + if (_dragInfo != null) { + _dragUpdateItems(); + _autoScrollIfNecessary(); + } + } + } + + void _dragEnd(_DragInfo item) { + widget.onReorderEnd?.call(_insertIndex!); + setState(() => _finalDropPosition = _itemOffsetAt(_insertIndex!)); + } + + void _dropCompleted() { + final int fromIndex = _dragIndex!; + final int toIndex = _insertIndex!; + if (fromIndex != toIndex) { + widget.onReorder?.call(fromIndex, toIndex); + } + setState(() { + _dragReset(); + }); + } + + void cancelReorder() { + setState(() { + _dragReset(); + }); + } + + void _dragReset() { + if (_dragInfo != null) { + if (_dragIndex != null && _items.containsKey(_dragIndex)) { + final MotionAnimatedContentState dragItem = _items[_dragIndex]!; + dragItem.dragging = false; + dragItem.dragSize = Size.zero; + dragItem.rebuild(); + _dragIndex = null; + } + _dragInfo?.dispose(); + _dragInfo = null; + _autoScroller?.stopAutoScroll(); + _resetItemGap(); + _recognizer?.dispose(); + _recognizer = null; + _overlayEntry?.remove(); + _overlayEntry?.dispose(); + _overlayEntry = null; + _finalDropPosition = null; + } + } + + void _resetItemGap() { + for (final MotionAnimatedContentState item in _items.values) { + item.resetGap(); + } + } + + void _dragUpdateItems() { + assert(_dragInfo != null); + + int newIndex = _insertIndex!; + + final dragCenter = _dragInfo!.itemSize + .center(_dragInfo!.dragPosition - _dragInfo!.dragOffset); + + for (final MotionAnimatedContentState item in _items.values) { + if (!item.mounted) continue; + final Rect geometry = item.targetGeometryNonOffset(); + + if (geometry.contains(dragCenter)) { + newIndex = item.index; + break; + } + } + + if (newIndex == _insertIndex) return; + _insertIndex = newIndex; + + for (final MotionAnimatedContentState item in _items.values) { + if (item.index == _dragIndex) continue; + item.updateForGap(true); + } + } + + Offset calculateNextDragOffset(int index) { + int minPos = min(_dragIndex!, _insertIndex!); + int maxPos = max(_dragIndex!, _insertIndex!); + if (index < minPos || index > maxPos) return Offset.zero; + + final int direction = _insertIndex! > _dragIndex! ? -1 : 1; + if (isGrid) { + return _itemOffsetAt(index + direction) - _itemOffsetAt(index); + } else { + final Offset offset = + _extentOffset(_dragInfo!.itemExtent, scrollDirection); + return _insertIndex! > _dragIndex! ? -offset : offset; + } + } + + void registerItem(MotionAnimatedContentState item) { + _items[item.index] = item; + if (item.index == _dragInfo?.index) { + item.dragging = true; + item.dragSize = _dragInfo!.itemSize; + item.rebuild(); + } + } + + void unregisterItem(int index, MotionAnimatedContentState item) { + final MotionAnimatedContentState? currentItem = _items[index]; + if (currentItem == item) { + _items.remove(index); + } + } + + @override + void dispose() { + for (final _ActiveItem item in _incomingItems.followedBy(_outgoingItems)) { + item.controller?.dispose(); + } + _dragReset(); + super.dispose(); + } + + _ActiveItem? _removeActiveItemAt(List<_ActiveItem> items, int itemIndex) { + final int i = binarySearch(items, _ActiveItem.index(itemIndex)); + return i == -1 ? null : items.removeAt(i); + } + + _ActiveItem? _activeItemAt(List<_ActiveItem> items, int itemIndex) { + final int i = binarySearch(items, _ActiveItem.index(itemIndex)); + return i == -1 ? null : items[i]; + } + + int _indexToItemIndex(int index) { + int itemIndex = index; + for (final _ActiveItem item in _outgoingItems) { + if (item.itemIndex <= itemIndex) { + itemIndex += 1; + } else { + break; + } + } + return itemIndex; + } + + int _itemIndexToIndex(int itemIndex) { + int index = itemIndex; + for (final _ActiveItem item in _outgoingItems) { + assert(item.itemIndex != itemIndex); + if (item.itemIndex < itemIndex) { + index -= 1; + } else { + break; + } + } + return index; + } + + void insertItem(int index, {required Duration insertDuration}) { + assert(index >= 0); + final int itemIndex = _indexToItemIndex(index); + assert(itemIndex >= 0 && itemIndex <= _itemsCount); + + for (final _ActiveItem item in _incomingItems) { + if (item.itemIndex >= itemIndex) item.itemIndex += 1; + } + for (final _ActiveItem item in _outgoingItems) { + if (item.itemIndex >= itemIndex) item.itemIndex += 1; + } + + final AnimationController controller = AnimationController( + duration: insertDuration, + vsync: this, + ); + final AnimationController sizeController = AnimationController( + duration: kAnimationDuration, + vsync: this, + ); + + final _ActiveItem incomingItem = _ActiveItem.animation( + controller, + itemIndex, + sizeController, + ); + + _incomingItems + ..add(incomingItem) + ..sort(); + + final motionData = + MotionData(endOffset: Offset.zero, startOffset: Offset.zero); + + final updatedChildrenMap = {}; + + if (childrenMap.containsKey(itemIndex)) { + for (final entry in childrenMap.entries) { + if (entry.key == itemIndex) { + updatedChildrenMap[itemIndex] = motionData.copyWith(visible: false); + updatedChildrenMap[entry.key + 1] = entry.value.copyWith( + startOffset: _itemOffsetAt(entry.key), + endOffset: getChildOffset(entry.key)); + } else if (entry.key > itemIndex) { + updatedChildrenMap[entry.key + 1] = entry.value.copyWith( + startOffset: _itemOffsetAt(entry.key), + endOffset: getChildOffset(entry.key)); + } else { + updatedChildrenMap[entry.key] = entry.value; + } + } + childrenMap.clear(); + childrenMap.addAll(updatedChildrenMap); + sizeController.forward().then((value) { + controller.forward().then((_) { + _removeActiveItemAt(_incomingItems, incomingItem.itemIndex)! + .controller! + .dispose(); + }); + }); + } else { + childrenMap[itemIndex] = motionData; + sizeController.value = kAlwaysCompleteAnimation.value; + controller.forward().then((_) { + _removeActiveItemAt(_incomingItems, incomingItem.itemIndex)! + .controller! + .dispose(); + }); + } + setState(() { + _itemsCount += 1; + }); + } + + void removeItem(int index, {required Duration removeItemDuration}) { + assert(index >= 0); + final int itemIndex = _indexToItemIndex(index); + if (itemIndex < 0 || itemIndex >= _itemsCount) { + return; + } + assert(itemIndex >= 0 && itemIndex < _itemsCount); + + assert(_activeItemAt(_outgoingItems, itemIndex) == null); + + if (childrenMap.containsKey(itemIndex)) { + final _ActiveItem? incomingItem = + _removeActiveItemAt(_incomingItems, itemIndex); + + final AnimationController sizeController = incomingItem?.sizeAnimation ?? + AnimationController( + vsync: this, duration: kAnimationDuration, value: 1.0); + final AnimationController controller = incomingItem?.controller ?? + AnimationController( + duration: removeItemDuration, value: 1.0, vsync: this) + ..addStatusListener((status) => ()); + final _ActiveItem outgoingItem = + _ActiveItem.animation(controller, itemIndex, sizeController); + _outgoingItems + ..add(outgoingItem) + ..sort(); + + controller.reverse().then((void value) { + if (controller.status == AnimationStatus.dismissed) { + if (childrenMap.containsKey(itemIndex)) { + childrenMap.update( + itemIndex, (value) => value.copyWith(visible: false)); + } + sizeController.reverse(from: 1.0).then((value) { + final removedItem = + _removeActiveItemAt(_outgoingItems, outgoingItem.itemIndex)!; + removedItem.controller!.dispose(); + removedItem.sizeAnimation!.dispose(); + + // Decrement the incoming and outgoing item indices to account + // for the removal. + for (final _ActiveItem item in _incomingItems) { + if (item.itemIndex > outgoingItem.itemIndex) item.itemIndex -= 1; + } + for (final _ActiveItem item in _outgoingItems) { + if (item.itemIndex > outgoingItem.itemIndex) item.itemIndex -= 1; + } + _onItemRemoved(itemIndex, removeItemDuration); + }); + } + }); + } + } + + void _onItemRemoved(int itemIndex, Duration removeDuration) { + final updatedChildrenMap = {}; + if (childrenMap.containsKey(itemIndex)) { + for (final entry in childrenMap.entries) { + if (entry.key < itemIndex) { + updatedChildrenMap[entry.key] = childrenMap[entry.key]!; + } else if (entry.key == itemIndex) { + continue; + } else { + updatedChildrenMap[entry.key - 1] = childrenMap[entry.key]!.copyWith( + startOffset: _itemOffsetAt(entry.key), + endOffset: _itemOffsetAt(entry.key - 1)); + } + } + } + childrenMap.clear(); + childrenMap.addAll(updatedChildrenMap); + + setState(() => _itemsCount -= 1); + } + + Offset getChildOffset(int index) { + final currentOffset = _itemOffsetAt(index); + if (!isGrid) { + return currentOffset; + } + + if (widget.delegateBuilder + is SliverReorderableGridDelegateWithFixedCrossAxisCount) { + final delegateBuilder = widget.delegateBuilder + as SliverReorderableGridDelegateWithFixedCrossAxisCount; + return delegateBuilder.getOffset(index, currentOffset); + } else if (widget.delegateBuilder + is SliverReorderableGridWithMaxCrossAxisExtent) { + final delegateBuilder = + widget.delegateBuilder as SliverReorderableGridWithMaxCrossAxisExtent; + final offset = delegateBuilder.getOffset(index, currentOffset); + return offset; + } + return Offset.zero; + } + + Offset _itemOffsetAt(int index) { + final itemRenderBox = + _items[index]?.context.findRenderObject() as RenderBox?; + if (itemRenderBox == null) return Offset.zero; + return itemRenderBox.localToGlobal(Offset.zero); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.delegateBuilder != null + ? SliverGrid( + gridDelegate: widget.delegateBuilder!, delegate: _createDelegate()) + : SliverList(delegate: _createDelegate()); + } + + Widget _itemBuilder(BuildContext context, int index) { + final _ActiveItem? outgoingItem = _activeItemAt(_outgoingItems, index); + final _ActiveItem? incomingItem = _activeItemAt(_incomingItems, index); + + if (outgoingItem != null) { + final child = _items[index]!.widget; + return _removeItemBuilder(outgoingItem, child); + } + if (_dragInfo != null && index >= _itemsCount) { + return SizedBox.fromSize(size: _dragInfo!.itemSize); + } + + final Widget child = widget.onReorder != null + ? reorderableItemBuilder(context, _itemIndexToIndex(index)) + : widget.itemBuilder(context, _itemIndexToIndex(index)); + + assert(() { + if (child.key == null) { + throw FlutterError( + 'Every item of AnimatedReorderableList must have a unique key.', + ); + } + return true; + }()); + + final Key itemGlobalKey = _MotionBuilderItemGlobalKey(child.key!, this); + final Widget builder = _insertItemBuilder(incomingItem, child); + + final motionData = childrenMap[index]; + if (motionData == null) return builder; + final OverlayState overlay = Overlay.of(context, debugRequiredFor: widget); + + return MotionAnimatedContent( + index: index, + key: itemGlobalKey, + motionData: motionData, + isGrid: isGrid, + updateMotionData: (MotionData motionData) { + final itemOffset = _itemOffsetAt(index); + childrenMap[index] = motionData.copyWith( + startOffset: itemOffset, endOffset: itemOffset, visible: true); + }, + capturedThemes: + InheritedTheme.capture(from: context, to: overlay.context), + child: builder, + ); + } + + SliverChildDelegate _createDelegate() { + return SliverChildBuilderDelegate(_itemBuilder, childCount: _itemsCount); + } + + Widget reorderableItemBuilder(BuildContext context, int index) { + final Widget item = widget.itemBuilder(context, index); + + assert(() { + if (item.key == null) { + throw FlutterError( + 'Every item of AnimatedReorderableList must have a key.', + ); + } + return true; + }()); + final Key itemGlobalKey = _MotionBuilderItemGlobalKey(item.key!, this); + final Widget itemWithSemantics = _wrapWithSemantics(item, index); + + // if (widget.useDefaultDragListeners) { + if (!widget.longPressDraggable) { + return _wrapWithSemantics(item, index, itemGlobalKey); + } + if (widget.buildDefaultDragHandles) { + switch (Theme.of(context).platform) { + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + switch (widget.scrollDirection) { + case Axis.horizontal: + return Stack( + key: itemGlobalKey, + children: [ + itemWithSemantics, + Positioned.directional( + textDirection: Directionality.of(context), + start: 0, + end: 0, + bottom: 8, + child: Align( + alignment: Alignment.bottomCenter, + child: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ), + )) + ], + ); + case Axis.vertical: + return Stack( + key: itemGlobalKey, + children: [ + itemWithSemantics, + Positioned.directional( + textDirection: Directionality.of(context), + top: 0, + bottom: 0, + end: 8, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: ReorderableGridDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ), + )) + ], + ); + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + return ReorderableGridDelayedDragStartListener( + key: itemGlobalKey, index: index, child: item); + } + } + + const bool enable = true; + + return ReorderableGridDelayedDragStartListener( + key: itemGlobalKey, + index: index, + enabled: enable, + child: itemWithSemantics, + ); + } + + Widget _wrapWithSemantics(Widget child, int index, [dynamic key]) { + void reorder(int startIndex, int endIndex) { + if (startIndex != endIndex) { + widget.onReorder?.call(startIndex, endIndex); + } + } + + // First, determine which semantics actions apply. + final Map semanticsActions = + {}; + + // Create the appropriate semantics actions. + void moveToStart() => reorder(index, 0); + void moveToEnd() => reorder(index, _itemsCount); + void moveBefore() => reorder(index, index - 1); + // To move after, we go to index+2 because we are moving it to the space + // before index+2, which is after the space at index+1. + void moveAfter() => reorder(index, index + 2); + + final WidgetsLocalizations localizations = WidgetsLocalizations.of(context); + + // If the item can move to before its current position in the grid. + if (index > 0) { + semanticsActions[ + CustomSemanticsAction(label: localizations.reorderItemToStart)] = + moveToStart; + String reorderItemBefore = localizations.reorderItemUp; + if (widget.scrollDirection == Axis.horizontal) { + reorderItemBefore = Directionality.of(context) == TextDirection.ltr + ? localizations.reorderItemLeft + : localizations.reorderItemRight; + } + semanticsActions[CustomSemanticsAction(label: reorderItemBefore)] = + moveBefore; + } + + // If the item can move to after its current position in the grid. + if (index < _itemsCount - 1) { + String reorderItemAfter = localizations.reorderItemDown; + if (widget.scrollDirection == Axis.horizontal) { + reorderItemAfter = Directionality.of(context) == TextDirection.ltr + ? localizations.reorderItemRight + : localizations.reorderItemLeft; + } + semanticsActions[CustomSemanticsAction(label: reorderItemAfter)] = + moveAfter; + semanticsActions[ + CustomSemanticsAction(label: localizations.reorderItemToEnd)] = + moveToEnd; + } + + // We pass toWrap with a GlobalKey into the item so that when it + // gets dragged, the accessibility framework can preserve the selected + // state of the dragging item. + // + // We also apply the relevant custom accessibility actions for moving the item + // up, down, to the start, and to the end of the grid. + return MergeSemantics( + key: key, + child: Semantics( + customSemanticsActions: semanticsActions, + child: child, + ), + ); + } + + Widget _removeItemBuilder(_ActiveItem outgoingItem, Widget child) { + final Animation animation = + outgoingItem.controller ?? kAlwaysCompleteAnimation; + final Animation sizeAnimation = + outgoingItem.sizeAnimation ?? kAlwaysCompleteAnimation; + return SizeTransition( + sizeFactor: sizeAnimation, + child: widget.removeAnimationBuilder(context, child, animation)); + } + + Widget _insertItemBuilder(_ActiveItem? incomingItem, Widget child) { + final Animation animation = + incomingItem?.controller ?? kAlwaysCompleteAnimation; + final Animation sizeAnimation = + incomingItem?.sizeAnimation ?? kAlwaysCompleteAnimation; + return SizeTransition( + axis: widget.scrollDirection, + sizeFactor: sizeAnimation, + child: widget.insertAnimationBuilder(context, child, animation)); + } +} + +Offset _extentOffset(double extent, Axis scrollDirection) { + switch (scrollDirection) { + case Axis.horizontal: + return Offset(extent, 0.0); + case Axis.vertical: + return Offset(0.0, extent); + } +} + +@optionalTypeArgs +class _MotionBuilderItemGlobalKey extends GlobalObjectKey { + const _MotionBuilderItemGlobalKey(this.subKey, this.state) : super(subKey); + + final Key subKey; + final State state; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is _MotionBuilderItemGlobalKey && + other.subKey == subKey && + other.state == state; + } + + @override + int get hashCode => Object.hash( + subKey, + state, + ); +} + +class _ActiveItem implements Comparable<_ActiveItem> { + _ActiveItem.animation(this.controller, this.itemIndex, this.sizeAnimation); + + _ActiveItem.index(this.itemIndex) + : controller = null, + sizeAnimation = null; + + final AnimationController? controller; + final AnimationController? sizeAnimation; + int itemIndex; + + @override + int compareTo(_ActiveItem other) => itemIndex - other.itemIndex; +} diff --git a/lib/common/widgets/list/animated_reorderable_list/builder/motion_list_base.dart b/lib/common/widgets/list/animated_reorderable_list/builder/motion_list_base.dart new file mode 100644 index 00000000..843a9e75 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/builder/motion_list_base.dart @@ -0,0 +1,253 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/fade_in.dart'; +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'motion_animated_builder.dart'; + +typedef ItemBuilder = Widget Function( + BuildContext context, int index); + +typedef AnimatedWidgetBuilder = Widget Function( + Widget child, Animation animation); + +typedef EqualityChecker = bool Function(E, E); + +const Duration kAnimationDuration = Duration(milliseconds: 300); + +abstract class MotionListBase + extends StatefulWidget { + final ItemBuilder itemBuilder; + final List items; + final ReorderCallback? onReorder; + final void Function(int)? onReorderStart; + final void Function(int)? onReorderEnd; + final ReorderItemProxyDecorator? proxyDecorator; + final List? enterTransition; + final List? exitTransition; + final Duration? insertDuration; + final Duration? removeDuration; + final Axis scrollDirection; + final SliverGridDelegate? sliverGridDelegate; + final AnimatedWidgetBuilder? insertItemBuilder; + final AnimatedWidgetBuilder? removeItemBuilder; + final bool? buildDefaultDragHandles; + final bool useDefaultDragListeners; + final bool? longPressDraggable; + final bool Function(E a, E b)? isSameItem; + + const MotionListBase( + {super.key, + required this.items, + required this.itemBuilder, + this.onReorder, + this.onReorderEnd, + this.onReorderStart, + this.proxyDecorator, + this.enterTransition, + this.exitTransition, + this.insertDuration, + this.removeDuration, + required this.scrollDirection, + this.sliverGridDelegate, + this.insertItemBuilder, + this.removeItemBuilder, + this.buildDefaultDragHandles, + this.longPressDraggable, + required this.useDefaultDragListeners, + this.isSameItem}); +} + +abstract class MotionListBaseState< + W extends Widget, + B extends MotionListBase, + E extends Object> extends State with TickerProviderStateMixin { + late List oldList; + + Duration _enterDuration = kAnimationDuration; + Duration _exitDuration = kAnimationDuration; + + List _enterAnimations = []; + List _exitAnimations = []; + + Duration get enterDuration => _enterDuration; + + Duration get exitDuration => _exitDuration; + + @protected + GlobalKey listKey = GlobalKey(); + + @nonVirtual + @protected + MotionBuilderState get list => listKey.currentState!; + + @nonVirtual + @protected + ItemBuilder get itemBuilder => widget.itemBuilder; + + @nonVirtual + @protected + SliverGridDelegate? get sliverGridDelegate => widget.sliverGridDelegate; + + @nonVirtual + @protected + ReorderCallback? get onReorder => widget.onReorder; + + @nonVirtual + @protected + void Function(int)? get onReorderStart => widget.onReorderStart; + + @nonVirtual + @protected + void Function(int)? get onReorderEnd => widget.onReorderEnd; + + @nonVirtual + @protected + ReorderItemProxyDecorator? get proxyDecorator => widget.proxyDecorator; + + @nonVirtual + @protected + Duration get insertDuration => widget.insertDuration ?? enterDuration; + + @nonVirtual + @protected + Duration get removeDuration => widget.removeDuration ?? exitDuration; + + @protected + @nonVirtual + Axis get scrollDirection => widget.scrollDirection; + + @nonVirtual + @protected + List get enterTransition => widget.enterTransition ?? []; + + @nonVirtual + @protected + List get exitTransition => widget.exitTransition ?? []; + + @nonVirtual + @protected + bool get buildDefaultDragHandles => widget.buildDefaultDragHandles ?? false; + + @nonVirtual + @protected + bool get longPressDraggable => widget.longPressDraggable ?? false; + + @nonVirtual + @protected + bool get useDefaultDragListeners => widget.useDefaultDragListeners ?? true; + + @nonVirtual + @protected + bool Function(E a, E b) get isSameItem => + widget.isSameItem ?? (a, b) => a == b; + + @override + void initState() { + super.initState(); + oldList = List.from(widget.items); + + addEffects(enterTransition, _enterAnimations, enter: true); + addEffects(exitTransition, _exitAnimations, enter: false); + } + + @override + void didUpdateWidget(covariant B oldWidget) { + final newList = widget.items; + if (!listEquals(oldWidget.enterTransition, enterTransition)) { + _enterAnimations = []; + addEffects(enterTransition, _enterAnimations, enter: true); + } + if (!listEquals(oldWidget.exitTransition, exitTransition)) { + _exitAnimations = []; + addEffects(exitTransition, _exitAnimations, enter: false); + } + calculateDiff(oldList, newList); + oldList = List.from(newList); + super.didUpdateWidget(oldWidget); + } + + void addEffects(List effects, List enteries, + {required bool enter}) { + if (effects.isNotEmpty) { + for (AnimationEffect effect in effects) { + addEffect(effect, enteries, enter: enter); + } + } else { + addEffect(FadeIn(), enteries, enter: enter); + } + } + + void addEffect(AnimationEffect effect, List enteries, + {required bool enter}) { + Duration zero = Duration.zero; + final timeForAnimation = + (effect.delay ?? zero) + (effect.duration ?? kAnimationDuration); + if (enter) { + _enterDuration = + timeForAnimation > _enterDuration ? timeForAnimation : _enterDuration; + assert(_enterDuration >= zero, "Duration can not be negative"); + } else { + _exitDuration = + timeForAnimation > _exitDuration ? timeForAnimation : _exitDuration; + assert(_exitDuration >= zero, "Duration can not be negative"); + } + + EffectEntry entry = EffectEntry( + animationEffect: effect, + delay: effect.delay ?? zero, + duration: effect.duration ?? kAnimationDuration, + curve: effect.curve ?? Curves.linear); + + enteries.add(entry); + } + + void calculateDiff(List oldList, List newList) { + // Detect removed and updated items + for (int i = oldList.length - 1; i >= 0; i--) { + if (newList.indexWhere((element) => isSameItem(oldList[i], element)) == + -1) { + listKey.currentState!.removeItem(i, removeItemDuration: removeDuration); + } + } + // Detect added items + for (int i = 0; i < newList.length; i++) { + if (oldList.indexWhere((element) => isSameItem(newList[i], element)) == + -1) { + listKey.currentState!.insertItem(i, insertDuration: insertDuration); + } + } + } + + @nonVirtual + @protected + Widget insertAnimationBuilder( + BuildContext context, Widget child, Animation animation) { + if (widget.insertItemBuilder != null) { + return widget.insertItemBuilder!(child, animation); + } else { + Widget animatedChild = child; + for (EffectEntry entry in _enterAnimations) { + animatedChild = entry.animationEffect + .build(context, animatedChild, animation, entry, insertDuration); + } + return animatedChild; + } + } + + @nonVirtual + @protected + Widget removeAnimationBuilder( + BuildContext context, Widget child, Animation animation) { + if (widget.removeItemBuilder != null) { + return widget.removeItemBuilder!(child, animation); + } else { + Widget animatedChild = child; + for (EffectEntry entry in _exitAnimations) { + animatedChild = entry.animationEffect + .build(context, animatedChild, animation, entry, removeDuration); + } + return animatedChild; + } + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/builder/motion_list_impl.dart b/lib/common/widgets/list/animated_reorderable_list/builder/motion_list_impl.dart new file mode 100644 index 00000000..903e9d9d --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/builder/motion_list_impl.dart @@ -0,0 +1,97 @@ +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/provider/animation_effect.dart'; +import 'package:flutter/material.dart'; + +import 'motion_animated_builder.dart'; +import 'motion_list_base.dart'; + +class MotionListImpl extends MotionListBase { + const MotionListImpl({ + super.key, + required super.items, + required super.itemBuilder, + super.enterTransition, + super.exitTransition, + super.insertDuration, + super.removeDuration, + super.onReorder, + super.onReorderStart, + super.onReorderEnd, + super.proxyDecorator, + required super.scrollDirection, + super.insertItemBuilder, + super.removeItemBuilder, + super.buildDefaultDragHandles, + super.useDefaultDragListeners = true, + super.longPressDraggable, + super.isSameItem, + }); + + const MotionListImpl.grid({ + Key? key, + required List items, + required ItemBuilder itemBuilder, + required SliverGridDelegate sliverGridDelegate, + List? enterTransition, + List? exitTransition, + ReorderCallback? onReorder, + void Function(int)? onReorderStart, + void Function(int)? onReorderEnd, + ReorderItemProxyDecorator? proxyDecorator, + Duration? insertDuration, + Duration? removeDuration, + required Axis scrollDirection, + AnimatedWidgetBuilder? insertItemBuilder, + AnimatedWidgetBuilder? removeItemBuilder, + bool? buildDefaultDragHandles, + bool useDefaultDragListeners = true, + bool? longPressDraggable, + bool Function(E a, E b)? isSameItem, + }) : super( + key: key, + items: items, + itemBuilder: itemBuilder, + sliverGridDelegate: sliverGridDelegate, + enterTransition: enterTransition, + exitTransition: exitTransition, + insertDuration: insertDuration, + removeDuration: removeDuration, + onReorder: onReorder, + onReorderStart: onReorderStart, + onReorderEnd: onReorderEnd, + proxyDecorator: proxyDecorator, + scrollDirection: scrollDirection, + insertItemBuilder: insertItemBuilder, + removeItemBuilder: removeItemBuilder, + buildDefaultDragHandles: buildDefaultDragHandles, + longPressDraggable: longPressDraggable, + useDefaultDragListeners: useDefaultDragListeners, + isSameItem: isSameItem); + + @override + MotionListImplState createState() => MotionListImplState(); +} + +class MotionListImplState + extends MotionListBaseState, E> { + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasOverlay(context)); + return MotionBuilder( + key: listKey, + initialCount: oldList.length, + onReorder: onReorder, + onReorderStart: onReorderStart, + onReorderEnd: onReorderEnd, + proxyDecorator: proxyDecorator, + insertAnimationBuilder: insertAnimationBuilder, + removeAnimationBuilder: removeAnimationBuilder, + itemBuilder: itemBuilder, + scrollDirection: scrollDirection, + delegateBuilder: sliverGridDelegate, + buildDefaultDragHandles: buildDefaultDragHandles, + longPressDraggable: longPressDraggable, + useDefaultDragListeners: useDefaultDragListeners, + ); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/component/drag_item.dart b/lib/common/widgets/list/animated_reorderable_list/component/drag_item.dart new file mode 100644 index 00000000..7c731edf --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/component/drag_item.dart @@ -0,0 +1,181 @@ +part of '../builder/motion_animated_builder.dart'; + +typedef _DragItemUpdate = void Function( + _DragInfo item, Offset position, Offset delta); +typedef _DragItemCallback = void Function(_DragInfo item); + +class _DragInfo extends Drag { + final bool gridView; + final Axis scrollDirection; + final _DragItemUpdate? onUpdate; + final _DragItemCallback? onEnd; + final _DragItemCallback? onCancel; + final VoidCallback? onDragCompleted; + final ReorderItemProxyDecorator? proxyDecorator; + final TickerProvider tickerProvider; + + late MotionBuilderState listState; + late int index; + late Widget child; + late Offset dragPosition; + late Offset dragOffset; + late Size itemSize; + late double itemExtent; + late CapturedThemes capturedThemes; + ScrollableState? scrollable; + AnimationController? _proxyAnimation; + + _DragInfo({ + required MotionAnimatedContentState item, + Offset initialPosition = Offset.zero, + required this.gridView, + this.scrollDirection = Axis.vertical, + this.onUpdate, + this.onEnd, + this.onCancel, + this.onDragCompleted, + this.proxyDecorator, + required this.tickerProvider, + }) { + final RenderBox itemRenderBox = + item.context.findRenderObject()! as RenderBox; + listState = item.listState; + index = item.index; + child = item.widget.child; + capturedThemes = item.widget.capturedThemes!; + dragPosition = initialPosition; + dragOffset = itemRenderBox.globalToLocal(initialPosition); + itemSize = item.context.size!; + itemExtent = _sizeExtent(itemSize, scrollDirection); + scrollable = Scrollable.of(item.context); + } + + void dispose() { + _proxyAnimation?.dispose(); + } + + void startDrag() { + _proxyAnimation = AnimationController( + vsync: tickerProvider, duration: const Duration(milliseconds: 250)) + ..addStatusListener((status) { + if (status == AnimationStatus.dismissed) { + _dropCompleted(); + } + }) + ..forward(); + } + + @override + void update(DragUpdateDetails details) { + final Offset delta = !gridView + ? _restrictAxis(details.delta, scrollDirection) + : details.delta; + dragPosition += delta; + onUpdate?.call(this, dragPosition, details.delta); + } + + @override + void end(DragEndDetails details) { + _proxyAnimation!.reverse(); + onEnd?.call(this); + } + + @override + void cancel() { + _proxyAnimation?.dispose(); + _proxyAnimation = null; + onCancel?.call(this); + } + + void _dropCompleted() { + _proxyAnimation?.dispose(); + _proxyAnimation = null; + onDragCompleted?.call(); + } + + Widget createProxy(BuildContext context) { + return capturedThemes.wrap(_DragItemProxy( + listState: listState, + index: index, + position: dragPosition - dragOffset - _overlayOrigin(context), + size: itemSize, + animation: _proxyAnimation!, + proxyDecorator: proxyDecorator, + child: child)); + } +} + +double _sizeExtent(Size size, Axis scrollDirection) { + switch (scrollDirection) { + case Axis.horizontal: + return size.width; + case Axis.vertical: + return size.height; + } +} + +Offset _restrictAxis(Offset offset, Axis scrollDirection) { + switch (scrollDirection) { + case Axis.horizontal: + return Offset(offset.dx, 0.0); + case Axis.vertical: + return Offset(0.0, offset.dy); + } +} + +Offset _overlayOrigin(BuildContext context) { + final OverlayState overlay = + Overlay.of(context, debugRequiredFor: context.widget); + final RenderBox overlayBox = overlay.context.findRenderObject()! as RenderBox; + return overlayBox.localToGlobal(Offset.zero); +} + +class _DragItemProxy extends StatelessWidget { + final MotionBuilderState listState; + final int index; + final Widget child; + final Offset position; + final Size size; + final AnimationController animation; + final ReorderItemProxyDecorator? proxyDecorator; + + const _DragItemProxy( + {required this.listState, + required this.index, + required this.child, + required this.position, + required this.size, + required this.animation, + required this.proxyDecorator}); + + @override + Widget build(BuildContext context) { + final Widget proxyChild = + proxyDecorator?.call(child, index, animation.view) ?? child; + final Offset overlayOrigin = _overlayOrigin(context); + return MediaQuery( + data: MediaQuery.of(context).removePadding(removeTop: true), + child: AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + Offset effectivePosition = position; + final Offset? dropPosition = listState._finalDropPosition; + if (dropPosition != null) { + effectivePosition = Offset.lerp( + dropPosition - overlayOrigin, + effectivePosition, + Curves.easeOut.transform(animation.value))!; + } + return Positioned( + left: effectivePosition.dx, + top: effectivePosition.dy, + child: SizedBox( + width: size.width, + height: size.height, + child: child, + )); + }, + child: proxyChild, + )); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/component/drag_listener.dart b/lib/common/widgets/list/animated_reorderable_list/component/drag_listener.dart new file mode 100644 index 00000000..8b6c7516 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/component/drag_listener.dart @@ -0,0 +1,95 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; + +import '../builder/motion_animated_builder.dart'; + +class ReorderableGridDragStartListener extends StatelessWidget { + /// Creates a listener for a drag immediately following a pointer down + /// event over the given child widget. + /// + /// This is most commonly used to wrap part of a grid item like a drag + /// handle. + const ReorderableGridDragStartListener({ + super.key, + required this.child, + required this.index, + this.enabled = true, + }); + + /// The widget for which the application would like to respond to a tap and + /// drag gesture by starting a reordering drag on a reorderable grid. + final Widget child; + + /// The index of the associated item that will be dragged in the grid. + final int index; + + /// Whether the [child] item can be dragged and moved in the grid. + /// + /// If true, the item can be moved to another location in the grid when the + /// user taps on the child. If false, tapping on the child will be ignored. + final bool enabled; + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: enabled + ? (PointerDownEvent event) => _startDragging(context, event) + : null, + child: child, + ); + } + + /// Provides the gesture recognizer used to indicate the start of a reordering + /// drag operation. + /// + /// By default this returns an [ImmediateMultiDragGestureRecognizer] but + /// subclasses can use this to customize the drag start gesture. + @protected + MultiDragGestureRecognizer createRecognizer() { + return DelayedMultiDragGestureRecognizer(debugOwner: this,delay: const Duration(milliseconds: 1)); + } + + void _startDragging(BuildContext context, PointerDownEvent event) { + final MotionBuilderState? list = MotionBuilder.maybeOf(context); + list?.startItemDragReorder( + index: index, + event: event, + recognizer: createRecognizer(), + ); + } +} + +/// A wrapper widget that will recognize the start of a drag operation by +/// looking for a long press event. Once it is recognized, it will start +/// a drag operation on the wrapped item in the reorderable grid. +/// +/// See also: +/// +/// * [ReorderableGridDragStartListener], a similar wrapper that will +/// recognize the start of the drag immediately after a pointer down event. +/// * [ReorderableGrid], a widget grid that allows the user to reorder +/// its items. +/// * [SliverReorderableGrid], a sliver grid that allows the user to reorder +/// its items. +/// * [ReorderableGridView], a material design grid that allows the user to +/// reorder its items. +class ReorderableGridDelayedDragStartListener + extends ReorderableGridDragStartListener { + /// Creates a listener for an drag following a long press event over the + /// given child widget. + /// + /// This is most commonly used to wrap an entire grid item in a reorderable + /// grid. + const ReorderableGridDelayedDragStartListener({ + super.key, + required super.child, + required super.index, + super.enabled, + }); + + @override + MultiDragGestureRecognizer createRecognizer() { + return DelayedMultiDragGestureRecognizer(debugOwner: this); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/component/motion_animated_content.dart b/lib/common/widgets/list/animated_reorderable_list/component/motion_animated_content.dart new file mode 100644 index 00000000..882aef28 --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/component/motion_animated_content.dart @@ -0,0 +1,221 @@ +part of '../builder/motion_animated_builder.dart'; + +class MotionAnimatedContent extends StatefulWidget { + final int index; + final MotionData motionData; + final Widget child; + final Function(MotionData)? updateMotionData; + final CapturedThemes? capturedThemes; + final bool isGrid; + + const MotionAnimatedContent({ + Key? key, + required this.index, + required this.motionData, + required this.child, + this.updateMotionData, + required this.capturedThemes, + required this.isGrid, + }) : super(key: key); + + @override + State createState() => MotionAnimatedContentState(); +} + +class MotionAnimatedContentState extends State + with SingleTickerProviderStateMixin { + late MotionBuilderState listState; + + Offset _targetOffset = Offset.zero; + Offset _startOffset = Offset.zero; + AnimationController? _offsetAnimation; + + bool _dragging = false; + + bool get dragging => _dragging; + + set dragging(bool dragging) { + if (mounted) { + setState(() { + _dragging = dragging; + }); + } + } + + Size _dragSize = Size.zero; + + set dragSize(Size itemSize) { + if (mounted) { + setState(() { + _dragSize = itemSize; + }); + } + } + + int get index => widget.index; + bool visible = true; + + @override + void initState() { + listState = MotionBuilder.of(context); + listState.registerItem(this); + visible = widget.motionData.visible; + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + widget.updateMotionData?.call(widget.motionData); + }); + Future.delayed(kAnimationDuration).then((value) { + visible = true; + rebuild(); + }); + super.initState(); + } + + @override + void didUpdateWidget(covariant MotionAnimatedContent oldWidget) { + if (oldWidget.index != widget.index) { + listState.unregisterItem(oldWidget.index, this); + listState.registerItem(this); + } + if (oldWidget.index != widget.index && !_dragging && widget.isGrid) { + _updateAnimationTranslation(); + } + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (mounted) { + setState(() { + visible = true; + }); + widget.updateMotionData?.call(widget.motionData); + } + }); + super.didUpdateWidget(oldWidget); + } + + void _updateAnimationTranslation() { + Offset offsetDiff = + (widget.motionData.startOffset + offset) - widget.motionData.endOffset; + _startOffset = offsetDiff; + if (offsetDiff.dx != 0 || offsetDiff.dy != 0) { + if (_offsetAnimation == null) { + _offsetAnimation = AnimationController( + vsync: listState, + duration: kAnimationDuration, + ) + ..addListener(rebuild) + ..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + widget.updateMotionData?.call(widget.motionData); + + _startOffset = _targetOffset; + _offsetAnimation!.dispose(); + _offsetAnimation = null; + } + }) + ..forward(); + } else { + _startOffset = offsetDiff; + _offsetAnimation!.forward(from: 0.0); + } + } + } + + Offset get offset { + if (_offsetAnimation != null) { + final Offset offset = + Offset.lerp(_startOffset, _targetOffset, _offsetAnimation!.value)!; + return offset; + } + return _targetOffset; + } + + void updateForGap(bool animate) { + if (!mounted) return; + final Offset newTargetOffset = listState.calculateNextDragOffset(index); + if (newTargetOffset == _targetOffset) return; + _targetOffset = newTargetOffset; + + if (animate) { + if (_offsetAnimation == null) { + _offsetAnimation = AnimationController( + vsync: listState, + duration: const Duration(milliseconds: 250), + ) + ..addListener(rebuild) + ..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + _startOffset = _targetOffset; + _offsetAnimation!.dispose(); + _offsetAnimation = null; + } + }) + ..forward(); + } else { + _startOffset = offset; + _offsetAnimation!.forward(from: 0.0); + } + } else { + if (_offsetAnimation != null) { + _offsetAnimation!.dispose(); + _offsetAnimation = null; + } + _startOffset = _targetOffset; + } + rebuild(); + } + + @override + Widget build(BuildContext context) { + listState.registerItem(this); + return Visibility( + maintainSize: true, + maintainAnimation: true, + maintainState: true, + visible: visible && !_dragging, + child: Transform.translate( + offset: offset, + child: + !_dragging ? widget.child : SizedBox.fromSize(size: _dragSize)), + ); + } + + Offset itemOffset() { + final box = context.findRenderObject() as RenderBox?; + if (box == null) return Offset.zero; + return box.localToGlobal(Offset.zero); + } + + void resetGap() { + if (_offsetAnimation != null) { + _offsetAnimation!.dispose(); + _offsetAnimation = null; + } + _startOffset = Offset.zero; + _targetOffset = Offset.zero; + rebuild(); + } + + Rect targetGeometryNonOffset() { + final RenderBox itemRenderBox = context.findRenderObject()! as RenderBox; + final Offset itemPosition = itemRenderBox.localToGlobal(Offset.zero); + return itemPosition & itemRenderBox.size; + } + + void rebuild() { + if (mounted) { + setState(() {}); + } + } + + @override + void dispose() { + listState.unregisterItem(widget.index, this); + _offsetAnimation?.dispose(); + super.dispose(); + } + + @override + void deactivate() { + listState.unregisterItem(index, this); + super.deactivate(); + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/component/sliver_grid_with_fixed_cross_axis_count.dart b/lib/common/widgets/list/animated_reorderable_list/component/sliver_grid_with_fixed_cross_axis_count.dart new file mode 100644 index 00000000..188035ed --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/component/sliver_grid_with_fixed_cross_axis_count.dart @@ -0,0 +1,157 @@ +import 'dart:math'; +import 'package:flutter/rendering.dart'; + +class SliverGridWithCustomGeometryLayout extends SliverGridRegularTileLayout { + final SliverGridGeometry Function( + int index, SliverGridRegularTileLayout layout) geometryBuilder; + + const SliverGridWithCustomGeometryLayout({ + required this.geometryBuilder, + required int crossAxisCount, + required double mainAxisStride, + required double crossAxisStride, + required double childMainAxisExtent, + required double childCrossAxisExtent, + required bool reverseCrossAxis, + }) : assert(crossAxisCount > 0), + assert(mainAxisStride >= 0), + assert(crossAxisStride >= 0), + assert(childMainAxisExtent >= 0), + assert(childCrossAxisExtent >= 0), + super( + crossAxisCount: crossAxisCount, + mainAxisStride: mainAxisStride, + crossAxisStride: crossAxisStride, + childMainAxisExtent: childMainAxisExtent, + childCrossAxisExtent: childCrossAxisExtent, + reverseCrossAxis: reverseCrossAxis); + + @override + SliverGridGeometry getGeometryForChildIndex(int index) { + return geometryBuilder(index, this); + } +} + +/// Creates grid layouts with a fixed number of tiles in the cross axis. +/// +/// For example, if the grid is vertical, this delegate will create a layout +/// with a fixed number of columns. If the grid is horizontal, this delegate +/// will create a layout with a fixed number of rows. +/// +/// This delegate creates grids with equally sized and spaced tiles. + +class SliverReorderableGridDelegateWithFixedCrossAxisCount + extends SliverGridDelegateWithFixedCrossAxisCount { + /// The number of children in the cross axis. + final int crossAxisCount; + + /// The number of logical pixels between each child along the main axis. + final double mainAxisSpacing; + + /// The number of logical pixels between each child along the cross axis. + final double crossAxisSpacing; + + /// The ratio of the cross-axis to the main-axis extent of each child. + final double childAspectRatio; + + /// The extent of each tile in the main axis. If provided it would define the + /// logical pixels taken by each tile in the main-axis. + /// + /// If null, [childAspectRatio] is used instead. + final double? mainAxisExtent; + + double childCrossAxisExtent = 0.0; + double childMainAxisExtent = 0.0; + + /// Creates a delegate that makes grid layouts with a fixed number of tiles in + /// the cross axis. + /// + /// The `mainAxisSpacing`, `mainAxisExtent` and `crossAxisSpacing` arguments + /// must not be negative. The `crossAxisCount` and `childAspectRatio` + /// arguments must be greater than zero. + + SliverReorderableGridDelegateWithFixedCrossAxisCount({ + required this.crossAxisCount, + this.mainAxisSpacing = 0.0, + this.crossAxisSpacing = 0.0, + this.childAspectRatio = 1.0, + this.mainAxisExtent, + }) : assert(crossAxisCount > 0), + assert(mainAxisSpacing >= 0), + assert(crossAxisSpacing >= 0), + assert(childAspectRatio > 0), + super( + crossAxisCount: crossAxisCount, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + childAspectRatio: childAspectRatio, + ); + + bool _debugAssertIsValid() { + assert(crossAxisCount > 0); + assert(mainAxisSpacing >= 0); + assert(crossAxisSpacing >= 0); + assert(childAspectRatio > 0); + return true; + } + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + assert(_debugAssertIsValid()); + final usableCrossAxisCount = max(0.0, + constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1)); + + childCrossAxisExtent = usableCrossAxisCount / crossAxisCount; + childMainAxisExtent = childCrossAxisExtent / childAspectRatio; + return SliverGridWithCustomGeometryLayout( + geometryBuilder: (index, layout) { + return SliverGridGeometry( + scrollOffset: (index ~/ crossAxisCount) * layout.mainAxisStride, + crossAxisOffset: _getOffsetFromStartInCrossAxis(index, layout), + mainAxisExtent: childMainAxisExtent, + crossAxisExtent: childCrossAxisExtent); + }, + crossAxisCount: crossAxisCount, + mainAxisStride: childMainAxisExtent + mainAxisSpacing, + crossAxisStride: childCrossAxisExtent + crossAxisSpacing, + childMainAxisExtent: childMainAxisExtent, + childCrossAxisExtent: childCrossAxisExtent, + reverseCrossAxis: + axisDirectionIsReversed(constraints.crossAxisDirection)); + } + + Offset getOffset(int index, Offset currentOffset) { + final int col = index % crossAxisCount; + final crossAxisStart = crossAxisSpacing; + + if (col == crossAxisCount - 1) { + return Offset(crossAxisStart, currentOffset.dy + childMainAxisExtent); + } else { + return Offset(currentOffset.dx + childCrossAxisExtent, currentOffset.dy); + } + } + + double _getOffsetFromStartInCrossAxis( + int index, + SliverGridRegularTileLayout layout, + ) { + final crossAxisStart = (index % crossAxisCount) * layout.crossAxisStride; + + if (layout.reverseCrossAxis) { + return crossAxisCount * layout.crossAxisStride - + crossAxisStart - + layout.childCrossAxisExtent - + (layout.crossAxisStride - layout.childCrossAxisExtent); + } + return crossAxisStart; + } + + @override + bool shouldRelayout(SliverGridDelegateWithFixedCrossAxisCount oldDelegate) { + return oldDelegate.crossAxisCount != crossAxisCount || + oldDelegate.mainAxisSpacing != mainAxisSpacing || + oldDelegate.crossAxisSpacing != crossAxisSpacing || + oldDelegate.childAspectRatio != childAspectRatio || + oldDelegate.mainAxisExtent != mainAxisExtent; + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/component/sliver_grid_with_main_axis_extent.dart b/lib/common/widgets/list/animated_reorderable_list/component/sliver_grid_with_main_axis_extent.dart new file mode 100644 index 00000000..0c06521b --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/component/sliver_grid_with_main_axis_extent.dart @@ -0,0 +1,128 @@ +import 'package:flutter/rendering.dart'; + +import 'dart:math' as math; + +/// Creates grid layouts with tiles that each have a maximum cross-axis extent. +/// +/// This delegate will select a cross-axis extent for the tiles that is as +/// large as possible subject to the following conditions: +/// +/// - The extent evenly divides the cross-axis extent of the grid. +/// - The extent is at most [maxCrossAxisExtent]. +/// +/// For example, if the grid is vertical, the grid is 500.0 pixels wide, and +/// [maxCrossAxisExtent] is 150.0, this delegate will create a grid with 4 +/// columns that are 125.0 pixels wide. +/// +/// This delegate creates grids with equally sized and spaced tiles. + +class SliverReorderableGridWithMaxCrossAxisExtent + extends SliverGridDelegateWithMaxCrossAxisExtent { + /// The maximum extent of tiles in the cross axis. + /// + /// This delegate will select a cross-axis extent for the tiles that is as + /// large as possible subject to the following conditions: + /// + /// - The extent evenly divides the cross-axis extent of the grid. + /// - The extent is at most [maxCrossAxisExtent]. + /// + /// For example, if the grid is vertical, the grid is 500.0 pixels wide, and + /// [maxCrossAxisExtent] is 150.0, this delegate will create a grid with 4 + /// columns that are 125.0 pixels wide. + final double maxCrossAxisExtent; + + /// The number of logical pixels between each child along the main axis. + final double mainAxisSpacing; + + /// The number of logical pixels between each child along the cross axis. + final double crossAxisSpacing; + + /// The ratio of the cross-axis to the main-axis extent of each child. + final double childAspectRatio; + + /// The extent of each tile in the main axis. If provided it would define the + /// logical pixels taken by each tile in the main-axis. + /// + /// If null, [childAspectRatio] is used instead. + final double? mainAxisExtent; + int crossAxisCount = 0; + double childCrossAxisExtent = 0.0; + double childMainAxisExtent = 0.0; + + /// Creates a delegate that makes grid layouts with tiles that have a maximum + /// cross-axis extent. + /// + /// The [maxCrossAxisExtent], [mainAxisExtent], [mainAxisSpacing], + /// and [crossAxisSpacing] arguments must not be negative. + /// The [childAspectRatio] argument must be greater than zero. + SliverReorderableGridWithMaxCrossAxisExtent({ + required this.maxCrossAxisExtent, + this.mainAxisSpacing = 0.0, + this.crossAxisSpacing = 0.0, + this.childAspectRatio = 1.0, + this.mainAxisExtent, + }) : assert(maxCrossAxisExtent > 0), + assert(mainAxisSpacing >= 0), + assert(crossAxisSpacing >= 0), + assert(childAspectRatio > 0), + super( + maxCrossAxisExtent: maxCrossAxisExtent, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + childAspectRatio: childAspectRatio, + mainAxisExtent: mainAxisExtent); + + bool _debugAssertIsValid(double crossAxisExtent) { + assert(maxCrossAxisExtent > 0); + assert(mainAxisSpacing >= 0); + assert(crossAxisSpacing >= 0); + assert(childAspectRatio > 0); + return true; + } + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + assert(_debugAssertIsValid(constraints.crossAxisExtent)); + int childCrossAxisCount = + (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)) + .ceil(); + // Ensure a minimum count of 1, can be zero and result in an infinite extent + // below when the window size is 0. + crossAxisCount = math.max(1, childCrossAxisCount); + final double usableCrossAxisExtent = math.max( + 0.0, + constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1), + ); + childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; + childMainAxisExtent = + mainAxisExtent ?? childCrossAxisExtent / childAspectRatio; + return SliverGridRegularTileLayout( + crossAxisCount: crossAxisCount, + mainAxisStride: childMainAxisExtent + mainAxisSpacing, + crossAxisStride: childCrossAxisExtent + crossAxisSpacing, + childMainAxisExtent: childMainAxisExtent, + childCrossAxisExtent: childCrossAxisExtent, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + Offset getOffset(int index, Offset currentOffset) { + final int col = index % crossAxisCount; + final crossAxisStart = crossAxisSpacing; + + if (col == crossAxisCount - 1) { + return Offset(crossAxisStart, currentOffset.dy + childMainAxisExtent); + } else { + return Offset(currentOffset.dx + childCrossAxisExtent, currentOffset.dy); + } + } + + @override + bool shouldRelayout(SliverGridDelegateWithMaxCrossAxisExtent oldDelegate) { + return oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || + oldDelegate.mainAxisSpacing != mainAxisSpacing || + oldDelegate.crossAxisSpacing != crossAxisSpacing || + oldDelegate.childAspectRatio != childAspectRatio || + oldDelegate.mainAxisExtent != mainAxisExtent; + } +} diff --git a/lib/common/widgets/list/animated_reorderable_list/model/motion_data.dart b/lib/common/widgets/list/animated_reorderable_list/model/motion_data.dart new file mode 100644 index 00000000..06de5a9f --- /dev/null +++ b/lib/common/widgets/list/animated_reorderable_list/model/motion_data.dart @@ -0,0 +1,30 @@ +import 'package:flutter/cupertino.dart'; + +class MotionData { + final Offset startOffset; + final Offset endOffset; + final bool visible; + + MotionData( + {this.startOffset = Offset.zero, + this.endOffset = Offset.zero, + this.visible = true}); + + MotionData copyWith({Offset? startOffset, Offset? endOffset, bool? visible}) { + return MotionData( + startOffset: startOffset ?? this.startOffset, + endOffset: endOffset ?? this.endOffset, + visible: visible ?? this.visible); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MotionData && + runtimeType == other.runtimeType && + startOffset == other.startOffset && + endOffset == other.endOffset; + + @override + int get hashCode => startOffset.hashCode ^ endOffset.hashCode; +} diff --git a/lib/common/widgets/list/custom_list_view.dart b/lib/common/widgets/list/custom_list_view.dart index 429eb7cb..1f978950 100644 --- a/lib/common/widgets/list/custom_list_view.dart +++ b/lib/common/widgets/list/custom_list_view.dart @@ -1,21 +1,16 @@ -import 'package:clock_app/common/logic/get_list_filter_chips.dart'; import 'package:clock_app/common/types/list_controller.dart'; import 'package:clock_app/common/types/list_filter.dart'; import 'package:clock_app/common/types/list_item.dart'; import 'package:clock_app/common/utils/reorderable_list_decorator.dart'; +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animated_reorderable_listview.dart'; +import 'package:clock_app/common/widgets/list/animated_reorderable_list/animation/fade_in.dart'; import 'package:clock_app/common/widgets/list/delete_alert_dialogue.dart'; -import 'package:clock_app/common/widgets/list/list_filter_chip.dart'; +import 'package:clock_app/common/widgets/list/list_filter_bar.dart'; import 'package:clock_app/common/widgets/list/list_item_card.dart'; +import 'package:clock_app/settings/data/general_settings_schema.dart'; +import 'package:clock_app/settings/data/settings_schema.dart'; +import 'package:clock_app/settings/types/setting.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:great_list_view/great_list_view.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -typedef ItemCardBuilder = Widget Function( - BuildContext context, - int index, - AnimatedWidgetBuilderData data, -); class CustomListView extends StatefulWidget { const CustomListView({ @@ -34,13 +29,13 @@ class CustomListView extends StatefulWidget { this.isDeleteEnabled = true, this.isDuplicateEnabled = true, this.shouldInsertOnTop = true, + this.isSelectable = false, this.listFilters = const [], this.customActions = const [], this.sortOptions = const [], this.initialSortIndex = 0, this.onChangeSortIndex, this.header, - }); final List items; @@ -63,6 +58,7 @@ class CustomListView extends StatefulWidget { final List> sortOptions; final Function(int index)? onChangeSortIndex; final Widget? header; + final bool isSelectable; @override State createState() => _CustomListViewState(); @@ -73,12 +69,24 @@ class _CustomListViewState late List currentList = List.from(widget.items); double _itemCardHeight = 0; final _scrollController = ScrollController(); - final _controller = AnimatedListController(); - late int selectedSortIndex = widget.initialSortIndex; + // final _controller = AnimatedListController(); + late int _selectedSortIndex = widget.initialSortIndex; + late Setting _longPressActionSetting; + List _selectedIds = []; + bool _isSelecting = false; + // bool _isReordering = false; @override void initState() { super.initState(); + + _longPressActionSetting = appSettings + .getGroup("General") + .getGroup("Interactions") + .getSetting("Long Press Action"); + + _longPressActionSetting.addListener(_handleUpdateSettings); + widget.listController.setChangeItems(_handleChangeItems); widget.listController.setAddItem(_handleAddItem); widget.listController.setDeleteItem(_handleDeleteItem); @@ -87,31 +95,37 @@ class _CustomListViewState widget.listController.setReloadItems(_handleReloadItems); widget.listController.setClearItems(_handleClear); widget.listController.setGetItems(() => widget.items); - updateCurrentList(); + _updateCurrentList(); // widget.listController.setChangeItemWithId(_handleChangeItemWithId); } + @override + void dispose() { + _longPressActionSetting.removeListener(_handleUpdateSettings); + super.dispose(); + } + + void _handleUpdateSettings(dynamic value) { + _endSelection(); + } + void _handleReloadItems(List items) { setState(() { widget.items.clear(); widget.items.addAll(items); - updateCurrentList(); + _updateCurrentList(); }); - // TODO: MAN THIS SUCKS, WHY YOU GOTTA DO THIS - _controller.notifyRemovedRange( - 0, widget.items.length - 1, _getChangeListBuilder()); - _controller.notifyInsertedRange(0, widget.items.length); } - void updateCurrentList() { - if (selectedSortIndex > widget.sortOptions.length) { - selectedSortIndex = 0; + void _updateCurrentList() { + if (_selectedSortIndex > widget.sortOptions.length) { + _selectedSortIndex = 0; } currentList.clear(); - if (selectedSortIndex != 0) { + if (_selectedSortIndex != 0) { final temp = [...widget.items]; - temp.sort(widget.sortOptions[selectedSortIndex - 1].sortFunction); + temp.sort(widget.sortOptions[_selectedSortIndex - 1].sortFunction); currentList.addAll(temp); } else { currentList.addAll(widget.items); @@ -123,39 +137,17 @@ class _CustomListViewState void _updateItemHeight() { if (_itemCardHeight == 0) { - _itemCardHeight = _controller.computeItemBox(0)?.height ?? 0; + // _itemCardHeight = _controller.computeItemBox(0)?.height ?? 0; } } - void _notifyChangeList() { - _controller.notifyChangedRange( - 0, - currentList.length, - _getChangeListBuilder(), - ); - } - - ItemCardBuilder _getChangeWidgetBuilder(Item item) { - _updateItemHeight(); - return (context, index, data) => data.measuring - ? SizedBox(height: _itemCardHeight) - : ListItemCard( - key: ValueKey(item), - onTap: () {}, - onDelete: () {}, - onDuplicate: () {}, - child: widget.itemBuilder(item), - ); - } - - ItemCardBuilder _getChangeListBuilder() => (context, index, data) => - _getChangeWidgetBuilder(widget.items[index])(context, index, data); - - bool _handleReorderItems(int oldIndex, int newIndex, Object? slot) { - if (newIndex >= widget.items.length || selectedSortIndex != 0) return false; + bool _handleReorderItems(int oldIndex, int newIndex) { + if (newIndex >= widget.items.length || _selectedSortIndex != 0) { + return false; + } widget.onReorderItem?.call(widget.items[oldIndex]); widget.items.insert(newIndex, widget.items.removeAt(oldIndex)); - updateCurrentList(); + _updateCurrentList(); widget.onModifyList?.call(); return true; @@ -163,77 +155,32 @@ class _CustomListViewState void _handleChangeItems( ItemChangerCallback callback, bool callOnModifyList) { - final initialList = List.from(currentList); - callback(widget.items); setState(() { - updateCurrentList(); + _updateCurrentList(); }); - final deletedItems = List.from(initialList - .where( - (element) => currentList.where((e) => e.id == element.id).isEmpty) - .toList()); - final addedItems = List.from(currentList - .where( - (element) => initialList.where((e) => e.id == element.id).isEmpty) - .toList()); - - for (var deletedItem in deletedItems) { - _controller.notifyRemovedRange( - initialList.indexWhere((element) => element.id == deletedItem.id), - 1, - _getChangeWidgetBuilder(deletedItem), - ); - } - - for (var addedItem in addedItems) { - _controller.notifyInsertedRange( - currentList.indexWhere((element) => element.id == addedItem.id), - 1, - ); - } - - _notifyChangeList(); - if (callOnModifyList) widget.onModifyList?.call(); } Future _handleDeleteItem(Item deletedItem, [bool callOnModifyList = true]) async { - int index = _getItemIndex(deletedItem); - - // print(listToString(widget.items)); - setState(() { widget.items.removeWhere((element) => element.id == deletedItem.id); - updateCurrentList(); + _updateCurrentList(); }); - _controller.notifyRemovedRange( - index, - 1, - _getChangeWidgetBuilder(deletedItem), - ); await widget.onDeleteItem?.call(deletedItem); if (callOnModifyList) widget.onModifyList?.call(); } Future _handleDeleteItemList(List deletedItems) async { for (var item in deletedItems) { - int index = _getItemIndex(item); - setState(() { widget.items.removeWhere((element) => element.id == item.id); - updateCurrentList(); + _updateCurrentList(); }); - - _controller.notifyRemovedRange( - index, - 1, - _getChangeWidgetBuilder(deletedItems.first), - ); } for (var item in deletedItems) { await widget.onDeleteItem?.call(item); @@ -253,16 +200,11 @@ class _CustomListViewState widget.items.insert(index, item); await widget.onAddItem?.call(item); setState(() { - updateCurrentList(); + _updateCurrentList(); }); int currentListIndex = _getItemIndex(item); - _controller.notifyInsertedRange(currentListIndex, 1); - // _scrollToIndex(index); - // TODO: Remove this delay - Future.delayed(const Duration(milliseconds: 100), () { - _scrollToIndex(currentListIndex); - }); + _scrollToIndex(currentListIndex); _updateItemHeight(); widget.onModifyList?.call(); } @@ -272,152 +214,155 @@ class _CustomListViewState } void _scrollToIndex(int index) { - // if (_scrollController.offset == 0) { - // _scrollController.jumpTo(1); - // } if (_itemCardHeight == 0 && index != 0) return; _scrollController.animateTo(index * _itemCardHeight, duration: const Duration(milliseconds: 250), curve: Curves.easeIn); } - _getItemBuilder() { - return (BuildContext context, Item item, data) { - for (var filter in widget.listFilters) { - // print("${filter.displayName} ${filter.filterFunction}"); - if (!filter.filterFunction(item)) { - return Container(); - } + void _endSelection() { + setState(() { + _isSelecting = false; + // _isReordering = false; + _selectedIds.clear(); + }); + } + + void _startSelection(Item item) { + setState(() { + _isSelecting = true; + _selectedIds = [item.id]; + }); + } + + void _handleSelect(Item item) { + setState(() { + if (_selectedIds.contains(item.id)) { + _selectedIds.remove(item.id); + } else { + _selectedIds.add(item.id); } - return data.measuring - ? SizedBox(height: _itemCardHeight) - : ListItemCard( - key: ValueKey(item), - onTap: () { - return widget.onTapItem?.call(item, _getItemIndex(item)); - }, - onDelete: - widget.isDeleteEnabled ? () => _handleDeleteItem(item) : null, - onDuplicate: () => _handleDuplicateItem(item), - isDeleteEnabled: item.isDeletable && widget.isDeleteEnabled, - isDuplicateEnabled: widget.isDuplicateEnabled, - child: widget.itemBuilder(item), - ); - }; + }); + if (_selectedIds.isEmpty) { + _endSelection(); + } + } + + void _handleSortChange(int index) { + setState(() { + _selectedSortIndex = index; + widget.onChangeSortIndex?.call(index); + _updateCurrentList(); + }); + } + + void _handleFilterChange() { + setState(() {}); } - void onFilterChange() { + void _handleSelectAll() { setState(() { - _notifyChangeList(); + _selectedIds = widget.items.map((e) => e.id).toList(); }); } - List getCurrentList() { - final List items = List.from(widget.items); + void _handleCustomAction(ListFilterCustomAction action) { + final list = _getActionableItems(); + List items = list.where((item) => + widget.listFilters.every((filter) => filter.filterFunction(item))).toList(); - if (selectedSortIndex != 0) { - items.sort(widget.sortOptions[selectedSortIndex - 1].sortFunction); - } + action.action(items); + _endSelection(); + } + + void _handleDeleteAction() async { + Navigator.pop(context); + final result = await showDeleteAlertDialogue(context); + if (result == null || result == false) return; - return items; + final list = _getActionableItems(); + final itemsToRemove = List.from(list.where((item) => + item.isDeletable && + widget.listFilters.every((filter) => filter.filterFunction(item)))); + _endSelection(); + await _handleDeleteItemList(itemsToRemove); + + widget.onModifyList?.call(); + } + + List _getActionableItems() { + return _isSelecting + ? widget.items.where((item) => _selectedIds.contains(item.id)).toList() + : widget.items; + } + + _getItemBuilder() { + return (BuildContext context, int index) { + Item item = currentList[index]; + for (var filter in widget.listFilters) { + if (!filter.filterFunction(item)) { + return Container(key: ValueKey(item)); + } + } + Widget itemWidget = ListItemCard( + key: ValueKey(item.id), + onTap: () { + if (_isSelecting) { + _handleSelect(item); + } else { + return widget.onTapItem?.call(item, index); + } + }, + onLongPress: () { + if (widget.isSelectable && + _longPressActionSetting.value == LongPressAction.multiSelect) { + if (!_isSelecting) { + _startSelection(item); + } else { + _handleSelect(item); + } + } + }, + onDelete: widget.isDeleteEnabled ? () => _handleDeleteItem(item) : null, + onDuplicate: () => _handleDuplicateItem(item), + isDeleteEnabled: item.isDeletable && widget.isDeleteEnabled, + isDuplicateEnabled: widget.isDuplicateEnabled, + isSelected: _selectedIds.contains(item.id), + showReorderHandle: + _isSelecting && widget.isReorderable && _selectedSortIndex == 0, + index: index, + child: widget.itemBuilder(item), + ); + return itemWidget; + }; } @override Widget build(BuildContext context) { ThemeData theme = Theme.of(context); ColorScheme colorScheme = theme.colorScheme; + TextTheme textTheme = theme.textTheme; - if (selectedSortIndex > widget.sortOptions.length) { - updateCurrentList(); + if (_selectedSortIndex > widget.sortOptions.length) { + _updateCurrentList(); } - List getFilterChips() { - List widgets = []; - int activeFilterCount = - widget.listFilters.where((filter) => filter.isActive).length; - if (activeFilterCount > 0) { - widgets.add(ListFilterActionChip( - actions: [ - ListFilterAction( - name: AppLocalizations.of(context)!.clearFiltersAction, - icon: Icons.clear_rounded, - action: () { - for (var filter in widget.listFilters) { - filter.reset(); - } - onFilterChange(); - }, - ), - ...widget.customActions.map((action) => ListFilterAction( - name: action.name, - icon: action.icon, - action: () => action.action(widget.items - .where((item) => widget.listFilters - .every((filter) => filter.filterFunction(item))) - .toList()), - )), - ListFilterAction( - name: AppLocalizations.of(context)!.deleteAllFilteredAction, - icon: Icons.delete_rounded, - color: colorScheme.error, - action: () async { - Navigator.pop(context); - final result = await showDeleteAlertDialogue(context); - if (result == null || result == false) return; - - final toRemove = List.from(widget.items.where((item) => - widget.listFilters - .every((filter) => filter.filterFunction(item)))); - await _handleDeleteItemList(toRemove); - - widget.onModifyList?.call(); - }, - ) - ], - activeFilterCount: activeFilterCount, - )); - } - widgets.addAll(widget.listFilters - .map((filter) => getListFilterChip(filter, onFilterChange))); - if (widget.sortOptions.isNotEmpty) { - widgets.add( - ListSortChip( - selectedIndex: selectedSortIndex, - sortOptions: [ - ListSortOption( - (context) => AppLocalizations.of(context)!.defaultLabel, - (a, b) => 0), - ...widget.sortOptions, - ], - onChange: (index) => setState(() { - selectedSortIndex = index; - widget.onChangeSortIndex?.call(index); - updateCurrentList(); - _notifyChangeList(); - }), - ), - ); - } - return widgets; - } - - // timeDilation = 1; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - flex: 0, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: getFilterChips(), - ), - ), - ), - ), - if(widget.header != null) widget.header!, + ListFilterBar( + listFilters: widget.listFilters, + customActions: widget.customActions, + sortOptions: widget.sortOptions, + isSelecting: _isSelecting, + handleCustomAction: _handleCustomAction, + handleEndSelection: _endSelection, + handleDeleteAction: _handleDeleteAction, + handleSelectAll: _handleSelectAll, + selectedIds: _selectedIds, + handleFilterChange: _handleFilterChange, + selectedSortIndex: _selectedSortIndex, + handleSortChange: _handleSortChange), + if (widget.header != null) widget.header!, Expanded( flex: 1, child: Stack(children: [ @@ -428,49 +373,30 @@ class _CustomListViewState child: Center( child: Text( widget.placeholderText, - style: - Theme.of(context).textTheme.displaySmall?.copyWith( - color: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.6), - ), + style: textTheme.displaySmall?.copyWith( + color: colorScheme.onBackground.withOpacity(0.6), + ), ), ), ) : Container(), - SlidableAutoCloseBehavior( - child: AutomaticAnimatedListView( - list: currentList, - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - comparator: AnimatedListDiffListComparator( - sameItem: (a, b) => a.id == b.id, - sameContent: (a, b) => a.id == b.id, - ), - itemBuilder: _getItemBuilder(), - // animator: DefaultAnimatedListAnimator, - listController: _controller, - scrollController: _scrollController, - addLongPressReorderable: widget.isReorderable, - reorderModel: widget.isReorderable && selectedSortIndex == 0 - ? AnimatedListReorderModel( - onReorderStart: (index, dx, dy) => true, - onReorderFeedback: (int index, int dropIndex, - double offset, double dx, double dy) => - null, - onReorderMove: (int index, int dropIndex) => true, - onReorderComplete: _handleReorderItems, - ) - : null, - reorderDecorationBuilder: - widget.isReorderable ? reorderableListDecorator : null, - footer: const SizedBox(height: 64 + 80), - // header: widget.header, - - // cacheExtent: double.infinity, - ), - ), + AnimatedReorderableListView( + longPressDraggable: false, + buildDefaultDragHandles: false, + proxyDecorator: (widget, index, animation) => + reorderableListDecorator(context, widget), + items: currentList, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + isSameItem: (a, b) => a.id == b.id, + scrollDirection: Axis.vertical, + itemBuilder: _getItemBuilder(), + enterTransition: [FadeIn()], + exitTransition: [FadeIn()], + controller: _scrollController, + insertDuration: const Duration(milliseconds: 300), + removeDuration: const Duration(milliseconds: 300), + onReorder: _handleReorderItems, + ) ]), ), ], diff --git a/lib/common/widgets/list/list_filter_bar.dart b/lib/common/widgets/list/list_filter_bar.dart new file mode 100644 index 00000000..a6d87910 --- /dev/null +++ b/lib/common/widgets/list/list_filter_bar.dart @@ -0,0 +1,136 @@ +import 'package:clock_app/common/logic/get_list_filter_chips.dart'; +import 'package:clock_app/common/types/json.dart'; +import 'package:clock_app/common/types/list_filter.dart'; +import 'package:clock_app/common/types/list_item.dart'; +import 'package:clock_app/common/widgets/list/list_filter_chip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class ListFilterBar extends StatelessWidget { + const ListFilterBar( + {super.key, + required this.listFilters, + required this.customActions, + required this.sortOptions, + required this.isSelecting, + required this.handleCustomAction, + required this.handleEndSelection, + required this.handleDeleteAction, + required this.handleSelectAll, + required this.selectedIds, + required this.handleFilterChange, + required this.selectedSortIndex, + required this.handleSortChange}); + + final List> listFilters; + final List> customActions; + final List> sortOptions; + final bool isSelecting; + final Function(ListFilterCustomAction) handleCustomAction; + final Function handleEndSelection; + final void Function() handleFilterChange; + final Function handleSelectAll; + final List selectedIds; + final int selectedSortIndex; + final void Function() handleDeleteAction; + final void Function(int) handleSortChange; + + @override + Widget build(BuildContext context) { + ThemeData theme = Theme.of(context); + ColorScheme colorScheme = theme.colorScheme; + TextTheme textTheme = theme.textTheme; + + List getFilterChips() { + List widgets = []; + int activeFilterCount = + listFilters.where((filter) => filter.isActive).length; + if (activeFilterCount > 0 || isSelecting) { + widgets.add( + ListFilterActionChip( + actions: [ + ListFilterAction( + name: AppLocalizations.of(context)!.clearFiltersAction, + icon: Icons.clear_rounded, + action: () { + for (var filter in listFilters) { + filter.reset(); + } + handleEndSelection(); + }, + ), + ...customActions.map( + (action) => ListFilterAction( + name: action.name, + icon: action.icon, + action: () => handleCustomAction(action), + ), + ), + ListFilterAction( + name: AppLocalizations.of(context)!.deleteAllFilteredAction, + icon: Icons.delete_rounded, + color: colorScheme.error, + action: handleDeleteAction, + ) + ], + activeFilterCount: activeFilterCount + (isSelecting ? 1 : 0), + ), + ); + } + + if (isSelecting) { + widgets.add( + ListButtonChip( + label: AppLocalizations.of(context)! + .selectionStatus(selectedIds.length), + icon: Icons.clear_rounded, + onTap: () => handleEndSelection(), + ), + ); + widgets.add( + ListButtonChip( + label: AppLocalizations.of(context)!.selectAll, + icon: Icons.select_all_rounded, + onTap: () => handleSelectAll(), + ), + ); + } + widgets.addAll(listFilters + .map((filter) => getListFilterChip(filter, handleFilterChange))); + if (sortOptions.isNotEmpty) { + widgets.add( + ListSortChip( + selectedIndex: selectedSortIndex, + sortOptions: [ + ListSortOption( + (context) => AppLocalizations.of(context)!.defaultLabel, + (a, b) => 0), + ...sortOptions, + ], + onChange: handleSortChange, + ), + ); + } + return widgets; + } + + return Expanded( + flex: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: AnimatedContainer( + duration: 150.ms, + height: getFilterChips().isEmpty ? 0 : 40, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: getFilterChips(), + ), + ), + ), + ), + ); + } +} diff --git a/lib/common/widgets/list/list_filter_chip.dart b/lib/common/widgets/list/list_filter_chip.dart index 38bb2890..4a1350ff 100644 --- a/lib/common/widgets/list/list_filter_chip.dart +++ b/lib/common/widgets/list/list_filter_chip.dart @@ -2,9 +2,11 @@ import 'package:clock_app/common/logic/show_select.dart'; import 'package:clock_app/common/types/list_filter.dart'; import 'package:clock_app/common/types/list_item.dart'; import 'package:clock_app/common/types/select_choice.dart'; +import 'package:clock_app/common/widgets/animated_show_hide.dart'; import 'package:clock_app/common/widgets/card_container.dart'; import 'package:clock_app/common/widgets/list/action_bottom_sheet.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class ListFilterChip extends StatelessWidget { @@ -23,20 +25,24 @@ class ListFilterChip extends StatelessWidget { ColorScheme colorScheme = theme.colorScheme; TextTheme textTheme = theme.textTheme; - return CardContainer( - color: listFilter.isSelected ? colorScheme.primary : null, - onTap: () { - listFilter.isSelected = !listFilter.isSelected; - onChange(); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Text( - listFilter.displayName(context), - style: textTheme.headlineSmall?.copyWith( - color: listFilter.isSelected - ? colorScheme.onPrimary - : colorScheme.onSurface, + return AnimatedShowHide( + duration: 200.ms, + axis: Axis.horizontal, + child: CardContainer( + color: listFilter.isSelected ? colorScheme.primary : null, + onTap: () { + listFilter.isSelected = !listFilter.isSelected; + onChange(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text( + listFilter.displayName(context), + style: textTheme.headlineSmall?.copyWith( + color: listFilter.isSelected + ? colorScheme.onPrimary + : colorScheme.onSurface, + ), ), ), ), @@ -44,6 +50,64 @@ class ListFilterChip extends StatelessWidget { } } +class ListButtonChip extends StatelessWidget { + const ListButtonChip({ + super.key, + required this.label, + this.onTap, + required this.icon, + this.isActive = false, + }); + + final String? label; + final IconData? icon; + final Function()? onTap; + final bool isActive; + + @override + Widget build(BuildContext context) { + ThemeData theme = Theme.of(context); + ColorScheme colorScheme = theme.colorScheme; + TextTheme textTheme = theme.textTheme; + + return AnimatedShowHide( + duration: 200.ms, + axis: Axis.horizontal, + child: CardContainer( + onTap: onTap, + color: isActive ? colorScheme.primary : null, + child: Row( + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.only( + left: 10.0, right: 6.0, top: 6.0, bottom: 6.0), + child: Icon( + icon, + color: + isActive ? colorScheme.onPrimary : colorScheme.onSurface, + size: 20, + ), + ), + if (label != null) + Padding( + padding: const EdgeInsets.only(right: 12.0), + child: Text( + label!, + style: textTheme.headlineSmall?.copyWith( + color: isActive + ? colorScheme.onPrimary + : colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + ); + } +} + class ListFilterActionChip extends StatelessWidget { const ListFilterActionChip({ super.key, @@ -75,34 +139,38 @@ class ListFilterActionChip extends StatelessWidget { ColorScheme colorScheme = theme.colorScheme; TextTheme textTheme = theme.textTheme; - return CardContainer( - color: colorScheme.primary, - onTap: () { - _showPopupMenu(context); - // listFilter.isSelected = !listFilter.isSelected; - // onChange(); - }, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only( - left: 8.0, right: 6.0, top: 6.0, bottom: 6.0), - child: Icon( - Icons.filter_list_rounded, - color: colorScheme.onPrimary, - size: 20, + return AnimatedShowHide( + duration: 200.ms, + axis: Axis.horizontal, + child: CardContainer( + color: colorScheme.primary, + onTap: () { + _showPopupMenu(context); + // listFilter.isSelected = !listFilter.isSelected; + // onChange(); + }, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 8.0, right: 6.0, top: 6.0, bottom: 6.0), + child: Icon( + Icons.filter_list_rounded, + color: colorScheme.onPrimary, + size: 20, + ), ), - ), - Padding( - padding: const EdgeInsets.only(right: 12.0), - child: Text( - activeFilterCount.toString(), - style: textTheme.headlineSmall?.copyWith( - color: colorScheme.onPrimary.withOpacity(0.6), + Padding( + padding: const EdgeInsets.only(right: 12.0), + child: Text( + activeFilterCount.toString(), + style: textTheme.headlineSmall?.copyWith( + color: colorScheme.onPrimary.withOpacity(0.6), + ), ), ), - ), - ], + ], + ), ), ); } @@ -141,34 +209,38 @@ class ListFilterSelectChip extends StatelessWidget { multiSelect: false); } - return CardContainer( - color: isFirstSelected ? null : colorScheme.primary, - onTap: showSelect, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only( - top: 8.0, bottom: 8.0, left: 16.0, right: 2.0), - child: Text( - isFirstSelected - ? listFilter.displayName(context) - : listFilter.selectedFilter.displayName(context), - style: textTheme.headlineSmall?.copyWith( - color: isFirstSelected - ? colorScheme.onSurface - : colorScheme.onPrimary), + return AnimatedShowHide( + duration: 200.ms, + axis: Axis.horizontal, + child: CardContainer( + color: isFirstSelected ? null : colorScheme.primary, + onTap: showSelect, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only( + top: 8.0, bottom: 8.0, left: 16.0, right: 2.0), + child: Text( + isFirstSelected + ? listFilter.displayName(context) + : listFilter.selectedFilter.displayName(context), + style: textTheme.headlineSmall?.copyWith( + color: isFirstSelected + ? colorScheme.onSurface + : colorScheme.onPrimary), + ), ), - ), - Padding( - padding: const EdgeInsets.only(left: 2.0, right: 8.0), - child: Icon( - Icons.keyboard_arrow_down_rounded, - color: isFirstSelected - ? colorScheme.onSurface.withOpacity(0.6) - : colorScheme.onPrimary.withOpacity(0.6), + Padding( + padding: const EdgeInsets.only(left: 2.0, right: 8.0), + child: Icon( + Icons.keyboard_arrow_down_rounded, + color: isFirstSelected + ? colorScheme.onSurface.withOpacity(0.6) + : colorScheme.onPrimary.withOpacity(0.6), + ), ), - ), - ], + ], + ), ), ); } @@ -208,36 +280,40 @@ class ListFilterMultiSelectChip extends StatelessWidget { multiSelect: true); } - return CardContainer( - color: isSelected ? colorScheme.primary : null, - onTap: showSelect, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only( - top: 8.0, bottom: 8.0, left: 16.0, right: 2.0), - child: Text( - !isSelected - ? listFilter.displayName(context) - : listFilter.selectedIndices.length == 1 - ? listFilter.selectedFilters[0].displayName(context) - : "${listFilter.selectedIndices.length} selected", - style: textTheme.headlineSmall?.copyWith( - color: isSelected - ? colorScheme.onPrimary - : colorScheme.onSurface), + return AnimatedShowHide( + duration: 200.ms, + axis: Axis.horizontal, + child: CardContainer( + color: isSelected ? colorScheme.primary : null, + onTap: showSelect, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only( + top: 8.0, bottom: 8.0, left: 16.0, right: 2.0), + child: Text( + !isSelected + ? listFilter.displayName(context) + : listFilter.selectedIndices.length == 1 + ? listFilter.selectedFilters[0].displayName(context) + : "${listFilter.selectedIndices.length} selected", + style: textTheme.headlineSmall?.copyWith( + color: isSelected + ? colorScheme.onPrimary + : colorScheme.onSurface), + ), ), - ), - Padding( - padding: const EdgeInsets.only(left: 2.0, right: 8.0), - child: Icon( - Icons.keyboard_arrow_down_rounded, - color: isSelected - ? colorScheme.onPrimary.withOpacity(0.6) - : colorScheme.onSurface.withOpacity(0.6), + Padding( + padding: const EdgeInsets.only(left: 2.0, right: 8.0), + child: Icon( + Icons.keyboard_arrow_down_rounded, + color: isSelected + ? colorScheme.onPrimary.withOpacity(0.6) + : colorScheme.onSurface.withOpacity(0.6), + ), ), - ), - ], + ], + ), ), ); } @@ -276,26 +352,30 @@ class ListSortChip extends StatelessWidget { multiSelect: false); } - return CardContainer( - // color: isFirstSelected ? null : colorScheme.primary, - onTap: showSelect, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only( - top: 8.0, bottom: 8.0, left: 16.0, right: 2.0), - child: Text( - "${AppLocalizations.of(context)!.sortGroup}${isFirstSelected ? "" : ": ${sortOptions[selectedIndex].displayName(context)}"}", - style: textTheme.headlineSmall - ?.copyWith(color: colorScheme.onSurface), + return AnimatedShowHide( + duration: 200.ms, + axis: Axis.horizontal, + child: CardContainer( + // color: isFirstSelected ? null : colorScheme.primary, + onTap: showSelect, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only( + top: 8.0, bottom: 8.0, left: 16.0, right: 2.0), + child: Text( + "${AppLocalizations.of(context)!.sortGroup}${isFirstSelected ? "" : ": ${sortOptions[selectedIndex].displayName(context)}"}", + style: textTheme.headlineSmall + ?.copyWith(color: colorScheme.onSurface), + ), ), - ), - Padding( - padding: const EdgeInsets.only(left: 2.0, right: 8.0), - child: Icon(Icons.keyboard_arrow_down_rounded, - color: colorScheme.onSurface.withOpacity(0.6)), - ), - ], + Padding( + padding: const EdgeInsets.only(left: 2.0, right: 8.0), + child: Icon(Icons.keyboard_arrow_down_rounded, + color: colorScheme.onSurface.withOpacity(0.6)), + ), + ], + ), ), ); } diff --git a/lib/common/widgets/list/list_item_card.dart b/lib/common/widgets/list/list_item_card.dart index 69a87597..eedab955 100644 --- a/lib/common/widgets/list/list_item_card.dart +++ b/lib/common/widgets/list/list_item_card.dart @@ -1,9 +1,12 @@ import 'package:clock_app/common/widgets/card_container.dart'; import 'package:clock_app/common/widgets/action_pane.dart'; +import 'package:clock_app/common/widgets/list/animated_reorderable_list/component/drag_listener.dart'; import 'package:clock_app/settings/data/general_settings_schema.dart'; import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:clock_app/settings/types/setting.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; class ListItemCard extends StatefulWidget { @@ -16,15 +19,23 @@ class ListItemCard extends StatefulWidget { this.onInit, this.isDeleteEnabled = true, this.isDuplicateEnabled = true, + this.isSelected = false, + this.showReorderHandle = false, + required this.index, + this.onLongPress, }); final VoidCallback? onDelete; final VoidCallback? onDuplicate; final VoidCallback? onTap; + final VoidCallback? onLongPress; final Widget child; final VoidCallback? onInit; final bool isDeleteEnabled; final bool isDuplicateEnabled; + final bool isSelected; + final bool showReorderHandle; + final int index; @override State createState() => _ListItemCardState(); @@ -32,6 +43,7 @@ class ListItemCard extends StatefulWidget { class _ListItemCardState extends State> { late Setting swipeActionSetting; + late Setting longPressActionSetting; void update(dynamic value) { setState(() {}); @@ -41,8 +53,11 @@ class _ListItemCardState extends State> { void initState() { super.initState(); widget.onInit?.call(); - swipeActionSetting = - appSettings.getGroup("General").getSetting("Swipe Action"); + final interactionSettingsGroup = + appSettings.getGroup("General").getGroup("Interactions"); + swipeActionSetting = interactionSettingsGroup.getSetting("Swipe Action"); + longPressActionSetting = + interactionSettingsGroup.getSetting("Long Press Action"); swipeActionSetting.addListener(update); } @@ -55,8 +70,11 @@ class _ListItemCardState extends State> { @override Widget build(BuildContext context) { Widget innerWidget = widget.child; + ThemeData theme = Theme.of(context); + ColorScheme colorScheme = theme.colorScheme; - if ((widget.isDeleteEnabled || widget.isDuplicateEnabled) && swipeActionSetting.value == SwipeAction.cardActions) { + if ((widget.isDeleteEnabled || widget.isDuplicateEnabled) && + swipeActionSetting.value == SwipeAction.cardActions) { ActionPane startActionPane = widget.isDuplicateEnabled ? getDuplicateActionPane(widget.onDuplicate ?? () {}, context) : getDeleteActionPane(widget.onDelete ?? () {}, context); @@ -64,6 +82,7 @@ class _ListItemCardState extends State> { ? getDeleteActionPane(widget.onDelete ?? () {}, context) : getDuplicateActionPane(widget.onDuplicate ?? () {}, context); innerWidget = Slidable( + enabled: !widget.showReorderHandle, groupTag: 'list', key: widget.key, startActionPane: startActionPane, @@ -76,7 +95,34 @@ class _ListItemCardState extends State> { width: double.infinity, child: CardContainer( onTap: widget.onTap, - child: innerWidget, + onLongPress: widget.onLongPress, + isSelected: widget.isSelected, + child: Row( + children: [ + AnimatedContainer( + duration: 150.ms, + width: widget.showReorderHandle ? 28 : 0, + color: Colors.transparent, + // decoration: const BoxDecoration(), + clipBehavior: Clip.hardEdge, + child: ReorderableGridDragStartListener( + + key: widget.key, + index: widget.index, + enabled: true, + child: Padding( + padding: + const EdgeInsets.only(left: 8.0, top: 16.0, bottom: 16.0), + child: Icon( + Icons.drag_indicator, + color: colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + ), + Expanded(child: innerWidget), + ], + ), ), ); } diff --git a/lib/common/widgets/list/persistent_list_view.dart b/lib/common/widgets/list/persistent_list_view.dart index fb7b3b6d..16220989 100644 --- a/lib/common/widgets/list/persistent_list_view.dart +++ b/lib/common/widgets/list/persistent_list_view.dart @@ -70,13 +70,14 @@ class PersistentListView extends StatefulWidget { this.isReorderable = true, this.isDeleteEnabled = true, this.isDuplicateEnabled = true, + this.isSelectable = false, this.reloadOnPop = false, this.shouldInsertOnTop = true, this.listFilters = const [], this.customActions = const [], this.sortOptions = const [], this.header, - this.onSaveItems = null, + this.onSaveItems , // this.initialSortIndex = 0, }); @@ -92,6 +93,7 @@ class PersistentListView extends StatefulWidget { final bool isDeleteEnabled; final bool isDuplicateEnabled; final bool reloadOnPop; + final bool isSelectable; final bool shouldInsertOnTop; final Widget? header; // final int initialSortIndex; @@ -190,6 +192,7 @@ class _PersistentListViewState onModifyList: _saveItems, isReorderable: widget.isReorderable, isDeleteEnabled: widget.isDeleteEnabled, + isSelectable: widget.isSelectable, isDuplicateEnabled: widget.isDuplicateEnabled, shouldInsertOnTop: widget.shouldInsertOnTop, listFilters: widget.listFilters, diff --git a/lib/common/widgets/measure_size.dart b/lib/common/widgets/measure_size.dart index 0cc621aa..37d962a8 100644 --- a/lib/common/widgets/measure_size.dart +++ b/lib/common/widgets/measure_size.dart @@ -27,10 +27,10 @@ class MeasureSize extends SingleChildRenderObjectWidget { final OnWidgetSizeChange onChange; const MeasureSize({ - Key? key, + super.key, required this.onChange, - required Widget child, - }) : super(key: key, child: child); + required Widget super.child, + }); @override RenderObject createRenderObject(BuildContext context) { diff --git a/lib/debug/logic/logger.dart b/lib/debug/logic/logger.dart new file mode 100644 index 00000000..f0fad6f8 --- /dev/null +++ b/lib/debug/logic/logger.dart @@ -0,0 +1,23 @@ +import 'package:clock_app/debug/types/file_logger_output.dart'; +import 'package:clock_app/debug/types/log_filter.dart'; +import 'package:logger/logger.dart'; +import 'dart:isolate'; + +var logger = Logger( + filter: FileLogFilter(), + output: FileLoggerOutput(), + printer: PrettyPrinter( + methodCount: 0, // Number of method calls to be displayed + errorMethodCount: 8, // Number of method calls if stacktrace is provided + lineLength: 80, // Width of the output + colors: true, // Colorful log messages + printEmojis: true, // Print an emoji for each log message + // Should each log print contain a timestamp + dateTimeFormat: DateTimeFormat.none, + ), +); + +void printIsolateInfo() { + logger.i( + "Isolate: ${Isolate.current.debugName}, id: ${Isolate.current.hashCode}"); +} diff --git a/lib/debug/types/file_logger_output.dart b/lib/debug/types/file_logger_output.dart new file mode 100644 index 00000000..dcb173ed --- /dev/null +++ b/lib/debug/types/file_logger_output.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:clock_app/app.dart'; +import 'package:clock_app/common/data/paths.dart'; +import 'package:clock_app/common/utils/snackbar.dart'; +import 'package:logger/logger.dart'; + +class FileLoggerOutput extends LogOutput { + FileLoggerOutput(); + + @override + void output(OutputEvent event) { + for (var line in event.lines) { + print(line); + } + + _writeLog(event.origin.message as String, event.level); + + Future(() { + if (event.level == Level.error && + App.navigatorKey.currentContext != null) { + showSnackBar( + App.navigatorKey.currentContext!, event.origin.message as String, + error: true, navBar: false, fab: false); + } + }); + } + + Future _writeLog(String message, Level level) async { + final DateTime currentDate = DateTime.now(); + final String dateString = + "${currentDate.day}-${currentDate.month}-${currentDate.year}"; + + final File file = File(await getLogsFilePath()); + + if (!(await file.exists())) { + await file.create(recursive: true); + } + + file.writeAsStringSync( + "[$dateString | ${currentDate.hour}:${currentDate.minute}:${currentDate.second}] [${level.name}] $message\n", + mode: FileMode.append, + ); + } +} diff --git a/lib/debug/types/log_filter.dart b/lib/debug/types/log_filter.dart new file mode 100644 index 00000000..508f8ed4 --- /dev/null +++ b/lib/debug/types/log_filter.dart @@ -0,0 +1,8 @@ +import 'package:logger/logger.dart'; + +class FileLogFilter extends LogFilter { + @override + bool shouldLog(LogEvent event) { + return true; + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 19e1b64f..71ca6c88 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -674,5 +674,35 @@ "cityAlreadyInFavorites": "Diese Stadt ist bereits in deinen Favoriten", "@cityAlreadyInFavorites": {}, "durationPickerTitle": "Dauer wählen", - "@durationPickerTitle": {} + "@durationPickerTitle": {}, + "hoursString": "{count, plural, =0{} =1{1 hour} other{{count} hours}}", + "@hoursString": {}, + "secondsString": "{count, plural, =0{} =1{1 second} other{{count} seconds}}", + "@secondsString": {}, + "daysString": "{count, plural, =0{} =1{1 day} other{{count} days}}", + "@daysString": {}, + "weeksString": "{count, plural, =0{} =1{1 week} other{{count} weeks}}", + "@weeksString": {}, + "monthsString": "{count, plural, =0{} =1{1 month} other{{count} months}}", + "@monthsString": {}, + "yearsString": "{count, plural, =0{} =1{1 year} other{{count} years}}", + "@yearsString": {}, + "lessThanOneMinute": "weniger als 1 Minute", + "@lessThanOneMinute": {}, + "alarmRingInMessage": "Der Alarm ertönt in {duration}", + "@alarmRingInMessage": {}, + "minutesString": "{count, plural, =0{} =1{1 minute} other{{count} minutes}}", + "@minutesString": {}, + "nextAlarmIn": "Nächste: {duration}", + "@nextAlarmIn": {}, + "combinedTime": "{hours} und {minutes}", + "@combinedTime": {}, + "shortHoursString": "{hours}h", + "@shortHoursString": {}, + "shortMinutesString": "{minutes}m", + "@shortMinutesString": {}, + "shortSecondsString": "{seconds}s", + "@shortSecondsString": {}, + "showNextAlarm": "Nächsten Alarm anzeigen", + "@showNextAlarm": {} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a94311d7..be91dd2a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -45,10 +45,14 @@ "@pickerInput": {}, "pickerSpinner": "Spinner", "@pickerSpinner": {}, + "pickerNumpad": "Numpad", + "@pickerNumpad": {}, "durationPickerSetting": "Duration Picker", "@durationPickerSetting": {}, "pickerRings": "Rings", "@pickerRings": {}, + "interactionsSettingGroup": "Interactions", + "@interactionsSettingGroup": {}, "swipeActionSetting": "Swipe Action", "@swipeActionSetting": {}, "swipActionCardAction": "Card Actions", @@ -59,6 +63,12 @@ "@swipActionSwitchTabs": {}, "swipeActionSwitchTabsDescription": "Swipe between tabs", "@swipeActionSwitchTabsDescription": {}, + "longPressActionSetting": "Long Press Action", + "@longPressActionSetting": {}, + "longPressReorderAction": "Reorder", + "@longPressReorderAction": {}, + "longPressSelectAction": "Multiselect", + "@longPressSelectAction": {}, "melodiesSetting": "Melodies", "@melodiesSetting": {}, "tagsSetting": "Tags", @@ -149,16 +159,22 @@ "@backupSettingGroup": {}, "developerOptionsSettingGroup": "Developer Options", "@developerOptionsSettingGroup": {}, - "showIstantAlarmButtonSetting": "Show Instant Alarm Button", + "showIstantAlarmButtonSetting": "Show instant alarm button", "@showIstantAlarmButtonSetting": {}, - "showIstantTimerButtonSetting": "Show Instant Timer Button", + "showIstantTimerButtonSetting": "Show instant timer button", "@showIstantTimerButtonSetting": {}, "logsSettingGroup": "Logs", "@logsSettingGroup": {}, - "maxLogsSetting": "Max Logs", + "maxLogsSetting": "Max alarm logs", "@maxLogsSetting": {}, - "alarmLogSetting": "Alarm Logs", + "alarmLogSetting": "Alarm logs", "@alarmLogSetting": {}, + "saveLogs": "Save logs", + "@saveLogs": {}, + "showErrorSnackbars": "Show error snackbars", + "@showErrorSnackbars": {}, + "clearLogs": "Clear logs", + "@clearLogs": {}, "aboutSettingGroup": "About", "@aboutSettingGroup": {}, "restoreSettingGroup": "Restore default values", @@ -291,6 +307,8 @@ "@audioChannelMedia": {}, "volumeSetting": "Volume", "@volumeSetting": {}, + "volumeWhileTasks": "Volume while solving Tasks", + "@volumeWhileTasks": {}, "risingVolumeSetting": "Rising Volume", "@risingVolumeSetting": {}, "timeToFullVolumeSetting": "Time to Full Volume", @@ -409,6 +427,12 @@ "@pausedTimerFilter": {}, "stoppedTimerFilter": "Stopped", "@stoppedTimerFilter": {}, + "selectionStatus": "{n} selected", + "@selectionStatus": {}, + "selectAll": "Select all", + "@selectAll": {}, + "reorder": "Reorder", + "@reorder": {}, "sortGroup": "Sort", "@sortGroup": {}, "defaultLabel": "Default", @@ -704,5 +728,13 @@ "shortSecondsString": "{seconds}s", "@shortSecondsString": {}, "showNextAlarm": "Show Next Alarm", - "@showNextAlarm": {} + "@showNextAlarm": {}, + "showForegroundNotification": "Show Foreground Notification", + "@showForegroundNotification": {}, + "showForegroundNotificationDescription": "Show a persistent notification to keep app alive", + "@showForegroundNotificationDescription": {}, + "notificationPermissionDescription": "Allow notifications to be showed", + "@notificationPermissionDescription": {}, + "extraAnimationSettingDescription": "Show animations that are not polished and might cause frame drops in low-end devices", + "@extraAnimationSettingDescription": {} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 61302315..5736ae8d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -666,5 +666,35 @@ "noTagsMessage": "No se crearon etiquetas", "@noTagsMessage": {}, "editTagLabel": "Editar etiqueta", - "@editTagLabel": {} + "@editTagLabel": {}, + "minutesString": "{count, plural, =0{} =1{1 minuto} other{{count} minutos}}", + "@minutesString": {}, + "hoursString": "{count, plural, =0{} =1{1 hora} other{{count} horas}}", + "@hoursString": {}, + "monthsString": "{count, plural, =0{} =1{1 mes} other{{count} meses}}", + "@monthsString": {}, + "yearsString": "{count, plural, =0{} =1{1 año} other{{count} años}}", + "@yearsString": {}, + "nextAlarmIn": "Siguiente: {duration}", + "@nextAlarmIn": {}, + "combinedTime": "{hours} y {minutes}", + "@combinedTime": {}, + "shortHoursString": "{hours}hr.", + "@shortHoursString": {}, + "alarmRingInMessage": "La alarma sonará en {duration}", + "@alarmRingInMessage": {}, + "shortMinutesString": "{minutes}min", + "@shortMinutesString": {}, + "shortSecondsString": "{seconds}seg.", + "@shortSecondsString": {}, + "secondsString": "{count, plural, =0{} =1{1 segundo} other{{count} segundos}}", + "@secondsString": {}, + "daysString": "{count, plural, =0{} =1{1 día} other{{count} días}}", + "@daysString": {}, + "weeksString": "{count, plural, =0{} =1{1 semana} other{{count} semanas}}", + "@weeksString": {}, + "lessThanOneMinute": "menos de 1 minuto", + "@lessThanOneMinute": {}, + "showNextAlarm": "Mostrar la siguiente alarma", + "@showNextAlarm": {} } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 091b51d4..8b497d52 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -29,7 +29,7 @@ "@displaySettingGroup": {}, "colorsSettingGroup": "Цвета", "@colorsSettingGroup": {}, - "useMaterialYouColorSetting": "Использовать Material You дизайн", + "useMaterialYouColorSetting": "Использовать Material You", "@useMaterialYouColorSetting": {}, "overrideAccentSetting": "Изменить цвет акцента", "@overrideAccentSetting": {}, @@ -235,7 +235,7 @@ "@deleteAllFilteredAction": {}, "editButton": "Редактировать", "@editButton": {}, - "noLapsMessage": "Еще нет кругов", + "noLapsMessage": "Кругов еще нет", "@noLapsMessage": {}, "fridayFull": "Пятница", "@fridayFull": {}, @@ -255,7 +255,7 @@ "@translateDescription": {}, "aboutSettingGroup": "О приложении", "@aboutSettingGroup": {}, - "reliabilitySettingGroup": "Надежность", + "reliabilitySettingGroup": "Стабильность", "@reliabilitySettingGroup": {}, "styleSettingGroup": "Стиль", "@styleSettingGroup": {}, @@ -674,5 +674,21 @@ "alarmDescriptionDates": "{date}{count, plural, =0{} =1{ и ещё 1 дата} other{ и ещё {count} дат(ы)}}", "@alarmDescriptionDates": {}, "alarmDescriptionRange": "{interval, select, daily{Ежедневно} weekly{Еженедельно} other{Другое}} с {startDate} по {endDate}", - "@alarmDescriptionRange": {} + "@alarmDescriptionRange": {}, + "lessThanOneMinute": "менее 1 минуты", + "@lessThanOneMinute": {}, + "alarmRingInMessage": "Будильник прозвонит через {duration}", + "@alarmRingInMessage": {}, + "nextAlarmIn": "Следующий: {duration}", + "@nextAlarmIn": {}, + "combinedTime": "{hours} и {minutes}", + "@combinedTime": {}, + "shortHoursString": "{hours}ч", + "@shortHoursString": {}, + "shortMinutesString": "{minutes}м", + "@shortMinutesString": {}, + "shortSecondsString": "{seconds}с", + "@shortSecondsString": {}, + "showNextAlarm": "Показать следующий будильник", + "@showNextAlarm": {} } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 66221052..814a26b8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -674,5 +674,35 @@ "tagNamePlaceholder": "标签名称", "@tagNamePlaceholder": {}, "longDateFormatSetting": "长日期格式", - "@longDateFormatSetting": {} + "@longDateFormatSetting": {}, + "alarmRingInMessage": "闹钟将于{duration}后响铃", + "@alarmRingInMessage": {}, + "nextAlarmIn": "下一次:{duration}", + "@nextAlarmIn": {}, + "showNextAlarm": "显示下一个闹钟", + "@showNextAlarm": {}, + "shortSecondsString": "{seconds}s", + "@shortSecondsString": {}, + "shortMinutesString": "{minutes}m", + "@shortMinutesString": {}, + "shortHoursString": "{hours}h", + "@shortHoursString": {}, + "lessThanOneMinute": "不到一分钟", + "@lessThanOneMinute": {}, + "hoursString": "{count, plural, =0{} =1{1 小时} other{{count} 小时}}", + "@hoursString": {}, + "minutesString": "{count, plural, =0{} =1{1 分钟} other{{count} 分钟}}", + "@minutesString": {}, + "secondsString": "{count, plural, =0{} =1{1 秒} other{{count} 秒}}", + "@secondsString": {}, + "daysString": "{count, plural, =0{} =1{1 天} other{{count} 天}}", + "@daysString": {}, + "weeksString": "{count, plural, =0{} =1{1 周} other{{count} 周}}", + "@weeksString": {}, + "yearsString": "{count, plural, =0{} =1{1 年} other{{count} 年}}", + "@yearsString": {}, + "monthsString": "{count, plural, =0{} =1{1 个月} other{{count} 个月}}", + "@monthsString": {}, + "combinedTime": "{hours}与{minutes}", + "@combinedTime": {} } diff --git a/lib/main.dart b/lib/main.dart index bfd15198..1d9b101c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,10 +8,10 @@ import 'package:clock_app/alarm/logic/update_alarms.dart'; import 'package:clock_app/app.dart'; import 'package:clock_app/audio/logic/audio_session.dart'; import 'package:clock_app/audio/types/ringtone_player.dart'; -import 'package:clock_app/clock/logic/timezone_database.dart'; import 'package:clock_app/common/data/paths.dart'; -import 'package:clock_app/common/utils/debug.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:clock_app/navigation/types/app_visibility.dart'; +import 'package:clock_app/notifications/logic/foreground_task.dart'; import 'package:clock_app/notifications/logic/notifications.dart'; import 'package:clock_app/settings/logic/initialize_settings.dart'; import 'package:clock_app/settings/types/listener_manager.dart'; @@ -25,6 +25,10 @@ import 'package:flutter_show_when_locked/flutter_show_when_locked.dart'; import 'package:timezone/data/latest_all.dart'; void main() async { + FlutterError.onError = (FlutterErrorDetails details) { + logger.f(details.exception.toString()); + }; + WidgetsFlutterBinding.ensureInitialized(); initializeTimeZones(); @@ -38,14 +42,17 @@ void main() async { RingtonePlayer.initialize(), initializeAudioSession(), FlutterShowWhenLocked().hide(), - initializeDatabases(), ]; await Future.wait(initializeData); + + // These rely on initializeAppDataDirectory await initializeStorage(); await initializeSettings(); + await updateAlarms("Update Alarms on Start"); await updateTimers("Update Timers on Start"); AppVisibility.initialize(); + initForegroundTask(); ReceivePort receivePort = ReceivePort(); IsolateNameServer.removePortNameMapping(updatePortName); diff --git a/lib/navigation/data/tabs.dart b/lib/navigation/data/tabs.dart index dd70ab1a..e9327fe6 100644 --- a/lib/navigation/data/tabs.dart +++ b/lib/navigation/data/tabs.dart @@ -1,4 +1,5 @@ import 'package:clock_app/alarm/screens/alarm_screen.dart'; +import 'package:clock_app/navigation/types/quick_action_controller.dart'; import 'package:clock_app/stopwatch/screens/stopwatch_screen.dart'; import 'package:clock_app/timer/screens/timer_screen.dart'; @@ -8,15 +9,28 @@ import 'package:clock_app/navigation/types/tab.dart'; import 'package:flutter/material.dart' hide Tab; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -List getTabs(BuildContext context) { +List getTabs(BuildContext context, + [QuickActionController? actionController]) { return [ - Tab(title: AppLocalizations.of(context)!.alarmTitle, icon: FluxIcons.alarm, widget: const AlarmScreen()), - Tab(title: AppLocalizations.of(context)!.clockTitle, icon: FluxIcons.clock, widget: const ClockScreen()), - Tab(title: AppLocalizations.of(context)!.timerTitle, icon: FluxIcons.timer, widget: const TimerScreen()), - Tab( - title: AppLocalizations.of(context)!.stopwatchTitle, - icon: FluxIcons.stopwatch, - widget: const StopwatchScreen()), -]; + Tab( + id: "alarm", + title: AppLocalizations.of(context)!.alarmTitle, + icon: FluxIcons.alarm, + widget: AlarmScreen(actionController: actionController)), + Tab( + id: "clock", + title: AppLocalizations.of(context)!.clockTitle, + icon: FluxIcons.clock, + widget: const ClockScreen()), + Tab( + id: "timer", + title: AppLocalizations.of(context)!.timerTitle, + icon: FluxIcons.timer, + widget: TimerScreen(actionController: actionController)), + Tab( + id: "stopwatch", + title: AppLocalizations.of(context)!.stopwatchTitle, + icon: FluxIcons.stopwatch, + widget: const StopwatchScreen()), + ]; } diff --git a/lib/navigation/screens/nav_scaffold.dart b/lib/navigation/screens/nav_scaffold.dart index 904a6acf..0a113fb1 100644 --- a/lib/navigation/screens/nav_scaffold.dart +++ b/lib/navigation/screens/nav_scaffold.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:isolate'; import 'package:clock_app/alarm/logic/new_alarm_snackbar.dart'; import 'package:clock_app/alarm/types/alarm.dart'; import 'package:clock_app/common/utils/snackbar.dart'; import 'package:clock_app/icons/flux_icons.dart'; import 'package:clock_app/navigation/data/tabs.dart'; +import 'package:clock_app/navigation/types/quick_action_controller.dart'; import 'package:clock_app/navigation/widgets/app_navigation_bar.dart'; import 'package:clock_app/navigation/widgets/app_top_bar.dart'; import 'package:clock_app/settings/data/general_settings_schema.dart'; @@ -12,10 +14,44 @@ import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:clock_app/settings/screens/settings_group_screen.dart'; import 'package:clock_app/settings/types/setting.dart'; import 'package:clock_app/system/logic/handle_intents.dart'; +import 'package:clock_app/system/logic/quick_actions.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:receive_intent/receive_intent.dart' as intent_handler; +// The callback function should always be a top-level function. +@pragma('vm:entry-point') +void startCallback() { + // The setTaskHandler function must be called to handle the task in the background. + FlutterForegroundTask.setTaskHandler(FirstTaskHandler()); +} + +class FirstTaskHandler extends TaskHandler { + SendPort? _sendPort; + + // Called when the task is started. + @override + void onStart(DateTime timestamp, SendPort? sendPort) async { + _sendPort = sendPort; + } + + @override + void onRepeatEvent(DateTime timestamp, SendPort? sendPort) async {} + + @override + void onDestroy(DateTime timestamp, SendPort? sendPort) async {} + + @override + void onNotificationButtonPressed(String id) {} + + @override + void onNotificationPressed() { + FlutterForegroundTask.launchApp("/"); + } +} + class NavScaffold extends StatefulWidget { const NavScaffold({super.key, this.initialTabIndex = 0}); @@ -29,16 +65,24 @@ class _NavScaffoldState extends State { late int _selectedTabIndex; late Setting useMaterialNavBarSetting; late Setting swipeActionSetting; + late Setting showForegroundSetting; late StreamSubscription _sub; late PageController _controller; + QuickActionController quickActionController = QuickActionController(); - void _onTabSelected(int index) { + void _onTabSelected(int index, [String? tabInitAction]) { ScaffoldMessenger.of(context).removeCurrentSnackBar(); setState(() { _controller.jumpToPage(index); _selectedTabIndex = index; }); + + if (tabInitAction != null) { + SchedulerBinding.instance.addPostFrameCallback((_) { + quickActionController.callAction(tabInitAction); + }); + } } void _handlePageViewChanged(int currentPageIndex) { @@ -56,8 +100,10 @@ class _NavScaffoldState extends State { ScaffoldMessenger.of(context).removeCurrentSnackBar(); DateTime? nextScheduleDateTime = alarm.currentScheduleDateTime; if (nextScheduleDateTime == null) return; - ScaffoldMessenger.of(context).showSnackBar( - getSnackbar(getNewAlarmText(context, alarm), fab: true, navBar: true)); + ScaffoldMessenger.of(context).showSnackBar(getSnackbar( + getNewAlarmText(context, alarm), + fab: true, + navBar: true)); }); } @@ -86,9 +132,29 @@ class _NavScaffoldState extends State { }); } + Future _updateForegroundNotification(dynamic value) async { + if (!value) { + return FlutterForegroundTask.stopService(); + } + if (await FlutterForegroundTask.isRunningService) { + return FlutterForegroundTask.updateService( + notificationTitle: 'Foreground service is running', + notificationText: '', + callback: startCallback, + ); + } else { + return FlutterForegroundTask.startService( + notificationTitle: 'Foreground service is running', + notificationText: '', + callback: startCallback, + ); + } + } + @override void initState() { super.initState(); + initializeQuickActions(context, _onTabSelected); initReceiveIntent(); useMaterialNavBarSetting = appSettings .getGroup("Appearance") @@ -96,16 +162,24 @@ class _NavScaffoldState extends State { .getSetting("Use Material Style"); swipeActionSetting = appSettings.getGroup("General").getSetting("Swipe Action"); + showForegroundSetting = appSettings + .getGroup("General") + .getGroup("Reliability") + .getSetting("Show Foreground Notification"); swipeActionSetting.addListener(update); useMaterialNavBarSetting.addListener(update); + showForegroundSetting.addListener(_updateForegroundNotification); _controller = PageController(initialPage: widget.initialTabIndex); _selectedTabIndex = widget.initialTabIndex; + + _updateForegroundNotification(showForegroundSetting.value); } @override void dispose() { useMaterialNavBarSetting.removeListener(update); swipeActionSetting.removeListener(update); + showForegroundSetting.removeListener(_updateForegroundNotification); _sub.cancel(); _controller.dispose(); super.dispose(); @@ -114,110 +188,113 @@ class _NavScaffoldState extends State { @override Widget build(BuildContext context) { Orientation orientation = MediaQuery.of(context).orientation; - final tabs = getTabs(context); - return Scaffold( - appBar: orientation == Orientation.portrait - ? AppTopBar( - title: Text( - tabs[_selectedTabIndex].title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.6), - ), - ), - actions: [ - IconButton( - onPressed: () { - ScaffoldMessenger.of(context).removeCurrentSnackBar(); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - SettingGroupScreen(settingGroup: appSettings))); - }, - icon: - const Icon(FluxIcons.settings, semanticLabel: "Settings"), - color: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.8), + final tabs = getTabs(context, quickActionController); + return WithForegroundTask( + child: Scaffold( + appBar: orientation == Orientation.portrait + ? AppTopBar( + title: Text( + tabs[_selectedTabIndex].title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.6), + ), ), - ], - ) - : null, - extendBody: false, - body: SafeArea( - child: Row( - children: [ - if (orientation == Orientation.landscape) - NavigationRail( - destinations: [ - for (final tab in tabs) - NavigationRailDestination( - icon: Icon(tab.icon), - label: Text(tab.title), - ) + actions: [ + IconButton( + onPressed: () { + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SettingGroupScreen( + settingGroup: appSettings))); + }, + icon: const Icon(FluxIcons.settings, + semanticLabel: "Settings"), + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.8), + ), ], - leading: Text(tabs[_selectedTabIndex].title, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.6), - )), - trailing: IconButton( - onPressed: () { - ScaffoldMessenger.of(context).removeCurrentSnackBar(); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - SettingGroupScreen(settingGroup: appSettings))); - }, - icon: - const Icon(FluxIcons.settings, semanticLabel: "Settings"), - color: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.8), - ), - selectedIndex: _selectedTabIndex, - onDestinationSelected: _onTabSelected, - ), - Expanded( - child: PageView( - controller: _controller, - onPageChanged: _handlePageViewChanged, - physics: swipeActionSetting.value == SwipeAction.cardActions - ? const NeverScrollableScrollPhysics() - : null, - children: tabs.map((tab) => tab.widget).toList()), - ), - ], - ), - ), - bottomNavigationBar: orientation == Orientation.portrait - ? useMaterialNavBarSetting.value - ? NavigationBar( - labelBehavior: - NavigationDestinationLabelBehavior.onlyShowSelected, - selectedIndex: _selectedTabIndex, - onDestinationSelected: _onTabSelected, - destinations: [ + ) + : null, + bottomNavigationBar: orientation == Orientation.portrait + ? useMaterialNavBarSetting.value + ? NavigationBar( + labelBehavior: + NavigationDestinationLabelBehavior.onlyShowSelected, + selectedIndex: _selectedTabIndex, + onDestinationSelected: _onTabSelected, + destinations: [ + for (final tab in tabs) + NavigationDestination( + icon: Icon(tab.icon), + label: tab.title, + ) + ], + ) + : AppNavigationBar( + selectedTabIndex: _selectedTabIndex, + onTabSelected: _onTabSelected, + ) + : null, + extendBody: false, + body: SafeArea( + child: Row( + children: [ + if (orientation == Orientation.landscape) + NavigationRail( + destinations: [ for (final tab in tabs) - NavigationDestination( + NavigationRailDestination( icon: Icon(tab.icon), - label: tab.title, + label: Text(tab.title), ) ], - ) - : AppNavigationBar( - selectedTabIndex: _selectedTabIndex, - onTabSelected: _onTabSelected, - ) - : null, + leading: Text(tabs[_selectedTabIndex].title, + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.6), + )), + trailing: IconButton( + onPressed: () { + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SettingGroupScreen( + settingGroup: appSettings))); + }, + icon: const Icon(FluxIcons.settings, + semanticLabel: "Settings"), + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.8), + ), + selectedIndex: _selectedTabIndex, + onDestinationSelected: _onTabSelected, + ), + Expanded( + child: PageView( + controller: _controller, + onPageChanged: _handlePageViewChanged, + physics: swipeActionSetting.value == SwipeAction.cardActions + ? const NeverScrollableScrollPhysics() + : null, + children: tabs.map((tab) => tab.widget).toList()), + ), + ], + ), + ), + ), ); } } diff --git a/lib/navigation/types/quick_action_controller.dart b/lib/navigation/types/quick_action_controller.dart new file mode 100644 index 00000000..778188f1 --- /dev/null +++ b/lib/navigation/types/quick_action_controller.dart @@ -0,0 +1,11 @@ +class QuickActionController { + Function(String name)? _action; + + void setAction(Function(String name)? action) { + _action = action; + } + + void callAction(String name) { + _action?.call(name); + } +} diff --git a/lib/navigation/types/tab.dart b/lib/navigation/types/tab.dart index 9f91654e..7fdfed3c 100644 --- a/lib/navigation/types/tab.dart +++ b/lib/navigation/types/tab.dart @@ -1,11 +1,13 @@ import 'package:flutter/widgets.dart'; class Tab { + final String id; final String title; final IconData icon; final Widget widget; Tab({ + required this.id, required this.title, required this.icon, required this.widget, diff --git a/lib/notifications/data/notification_channel.dart b/lib/notifications/data/notification_channel.dart index 4c7c1282..16714689 100644 --- a/lib/notifications/data/notification_channel.dart +++ b/lib/notifications/data/notification_channel.dart @@ -1,6 +1,7 @@ import 'package:awesome_notifications/awesome_notifications.dart'; import 'package:clock_app/theme/theme.dart'; +const String foregroundNotificationChannelKey = 'foreground'; const String chronoNotificationChannelGroupKey = 'chrono'; const String reminderNotificationChannelKey = 'reminders'; const String stopwatchNotificationChannelKey = 'stopwatch'; @@ -22,6 +23,23 @@ final NotificationChannel alarmNotificationChannel = NotificationChannel( enableLights: false, ); + +// final NotificationChannel foregroundNotificationChannel = NotificationChannel( +// icon: 'resource://drawable/alarm_icon', +// // channelGroupKey: chronoNotificationChannelGroupKey, +// channelKey: foregroundNotificationChannelKey, +// channelName: 'Foreground Service', +// channelDescription: 'Notification channel for foreground service', +// defaultColor: defaultColorScheme.accent, +// locked: true, +// importance: NotificationImportance.Low, +// criticalAlerts: false, +// playSound: false, +// enableVibration: false, +// enableLights: false, +// ); + + final NotificationChannel reminderNotificationChannel = NotificationChannel( icon: 'resource://drawable/alarm_icon', // channelGroupKey: chronoNotificationChannelGroupKey, diff --git a/lib/notifications/logic/foreground_task.dart b/lib/notifications/logic/foreground_task.dart new file mode 100644 index 00000000..b1de2b75 --- /dev/null +++ b/lib/notifications/logic/foreground_task.dart @@ -0,0 +1,34 @@ +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; + +void initForegroundTask() { + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'foreground_service', + channelName: 'Foreground Service Notification', + channelDescription: + 'This notification appears when the foreground service is running.', + channelImportance: NotificationChannelImportance.LOW, + priority: NotificationPriority.LOW, + iconData: const NotificationIconData( + resType: ResourceType.drawable, + resPrefix: ResourcePrefix.ic, + name: 'alarm_icon', + ), + // buttons: [ + // const NotificationButton(id: 'sendButton', text: 'Send'), + // const NotificationButton(id: 'testButton', text: 'Test'), + // ], + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: true, + playSound: false, + ), + foregroundTaskOptions: const ForegroundTaskOptions( + interval: 5000, + isOnceEvent: false, + autoRunOnBoot: true, + allowWakeLock: true, + allowWifiLock: true, + ), + ); +} diff --git a/lib/notifications/logic/notifications.dart b/lib/notifications/logic/notifications.dart index a4bd0bac..167ea376 100644 --- a/lib/notifications/logic/notifications.dart +++ b/lib/notifications/logic/notifications.dart @@ -25,7 +25,8 @@ Future initializeNotifications() async { alarmNotificationChannel, reminderNotificationChannel, stopwatchNotificationChannel, - timerNotificationChannel + timerNotificationChannel, + // foregroundNotificationChannel, ], // channelGroups: [alarmNotificationChannelGroup], debug: false, diff --git a/lib/notifications/types/fullscreen_notification_manager.dart b/lib/notifications/types/fullscreen_notification_manager.dart index 2cd40418..4dcb8499 100644 --- a/lib/notifications/types/fullscreen_notification_manager.dart +++ b/lib/notifications/types/fullscreen_notification_manager.dart @@ -12,7 +12,6 @@ import 'package:clock_app/notifications/data/notification_channel.dart'; import 'package:clock_app/alarm/logic/schedule_alarm.dart'; import 'package:clock_app/navigation/types/routes.dart'; import 'package:clock_app/notifications/types/fullscreen_notification_data.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart'; import 'package:flutter_show_when_locked/flutter_show_when_locked.dart'; import 'package:move_to_background/move_to_background.dart'; @@ -161,8 +160,9 @@ class AlarmNotificationManager { static Future stopAlarm(int scheduleId, ScheduledNotificationType type, AlarmStopAction action) async { // Send a message to tell the alarm isolate to run the code to stop alarm - SendPort? sendPort = IsolateNameServer.lookupPortByName(stopAlarmPortName); - sendPort?.send([scheduleId, type.name, action.name]); + // See stopScheduledNotification in lib/alarm/logic/alarm_isolate.dart + IsolateNameServer.lookupPortByName(stopAlarmPortName) + ?.send([scheduleId, type.name, action.name]); // await closeNotification(type); } diff --git a/lib/settings/data/appearance_settings_schema.dart b/lib/settings/data/appearance_settings_schema.dart index b4a8bd38..d53a9431 100644 --- a/lib/settings/data/appearance_settings_schema.dart +++ b/lib/settings/data/appearance_settings_schema.dart @@ -198,6 +198,24 @@ SettingGroup appearanceSettingsSchema = SettingGroup( ), ], ), + SettingGroup("Animations", + (context) => AppLocalizations.of(context)!.animationSettingGroup, [ + SliderSetting( + "Animation Speed", + (context) => AppLocalizations.of(context)!.animationSpeedSetting, + 0.5, + 2, + 1, + snapLength: 0.1, + ), + SwitchSetting( + "Extra Animations", + (context) => AppLocalizations.of(context)!.extraAnimationSetting, + false, + getDescription: (context) => + AppLocalizations.of(context)!.extraAnimationSettingDescription, + ), + ]) ], icon: Icons.palette_outlined, getDescription: (context) => diff --git a/lib/settings/data/backup_options.dart b/lib/settings/data/backup_options.dart new file mode 100644 index 00000000..c6fc30f3 --- /dev/null +++ b/lib/settings/data/backup_options.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; + +import 'package:clock_app/alarm/logic/update_alarms.dart'; +import 'package:clock_app/alarm/types/alarm.dart'; +import 'package:clock_app/app.dart'; +import 'package:clock_app/clock/types/city.dart'; +import 'package:clock_app/common/utils/json_serialize.dart'; +import 'package:clock_app/common/utils/list_storage.dart'; +import 'package:clock_app/settings/data/settings_schema.dart'; +import 'package:clock_app/settings/types/backup_option.dart'; +import 'package:clock_app/theme/types/color_scheme.dart'; +import 'package:clock_app/theme/types/style_theme.dart'; +import 'package:clock_app/timer/logic/update_timers.dart'; +import 'package:clock_app/timer/types/timer.dart'; +import 'package:clock_app/timer/types/timer_preset.dart'; +import 'package:clock_app/widgets/logic/update_widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +// Order of BackupOption matters +// tags should be before alarms and timers +// color_schemes and style_themes should be before settings +final backupOptions = [ + BackupOption( + "tags", + (context) => AppLocalizations.of(context)!.tagsSetting, + encode: () async { + return await loadTextFile("tags"); + }, + decode: (context, value) async { + await saveList("tags", [ + ...listFromString(value) + .map((tag) => TimerPreset.from(tag)), + ...await loadList("tags") + ]); + }, + ), + BackupOption( + "color_schemes", + (context) => AppLocalizations.of(context)!.colorSchemeSetting, + encode: () async { + List colorSchemes = + await loadList("color_schemes"); + List customColorSchemes = + colorSchemes.where((scheme) => !scheme.isDefault).toList(); + return listToString(customColorSchemes); + }, + decode: (context, value) async { + await saveList("color_schemes", [ + ...listFromString(value) + .map((scheme) => ColorSchemeData.from(scheme)), + ...await loadList("color_schemes") + ]); + if (context.mounted) App.refreshTheme(context); + }, + ), + BackupOption( + "style_themes", + (context) => AppLocalizations.of(context)!.styleThemeSetting, + encode: () async { + List styleThemes = await loadList("style_themes"); + List customThemes = + styleThemes.where((scheme) => !scheme.isDefault).toList(); + return listToString(customThemes); + }, + decode: (context, value) async { + await saveList("style_themes", [ + ...listFromString(value) + .map((theme) => StyleTheme.from(theme)), + ...await loadList("style_themes") + ]); + if (context.mounted) App.refreshTheme(context); + }, + ), + BackupOption( + "settings", + (context) => AppLocalizations.of(context)!.settings, + encode: () async { + return json.encode(appSettings.valueToJson()); + }, + decode: (context, value) async { + appSettings.loadValueFromJson(json.decode(value)); + appSettings.callAllListeners(); + App.refreshTheme(context); + await appSettings.save(); + if (context.mounted) { + setDigitalClockWidgetData(context); + } + }, + ), + BackupOption( + "alarms", + (context) => AppLocalizations.of(context)!.alarmTitle, + encode: () async { + return await loadTextFile("alarms"); + }, + decode: (context, value) async { + await saveList("alarms", [ + ...listFromString(value).map((alarm) => Alarm.fromAlarm(alarm)), + ...await loadList("alarms") + ]); + await updateAlarms("Updated alarms on importing backup"); + }, + ), + BackupOption( + "timers", + (context) => AppLocalizations.of(context)!.timerTitle, + encode: () async { + return await loadTextFile("timers"); + }, + decode: (context, value) async { + await saveList("timers", [ + ...listFromString(value) + .map((timer) => ClockTimer.from(timer)), + ...await loadList("timers") + ]); + await updateTimers("Updated timers on importing backup"); + }, + ), + BackupOption( + "favorite_cities", + (context) => AppLocalizations.of(context)!.clockTitle, + encode: () async { + return await loadTextFile("favorite_cities"); + }, + decode: (context, value) async { + await saveList("favorite_cities", [ + ...listFromString(value), + // ...await loadList("favorite_cities") + ]); + // await updateTimers("Updated timers on importing backup"); + }, + ), + + // BackupOption( + // "stopwatches", + // (context) => AppLocalizations.of(context)!.stopwatchTitle, + // encode: () async { + // return await loadTextFile("stopwatches"); + // }, + // decode: (context, value) async { + // await saveList("stopwatches", [ + // ...listFromString(value), + // ]); + // }, + // ), + + BackupOption( + "timer_presets", + (context) => AppLocalizations.of(context)!.presetsSetting, + encode: () async { + return await loadTextFile("timer_presets"); + }, + decode: (context, value) async { + await saveList("timer_presets", [ + ...listFromString(value) + .map((preset) => TimerPreset.from(preset)), + ...await loadList("timer_presets") + ]); + }, + ), +]; diff --git a/lib/settings/data/backup_settings_schema.dart b/lib/settings/data/backup_settings_schema.dart index e8e1e6b5..41e6d1f0 100644 --- a/lib/settings/data/backup_settings_schema.dart +++ b/lib/settings/data/backup_settings_schema.dart @@ -1,14 +1,9 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:clock_app/app.dart'; -import 'package:clock_app/settings/data/settings_schema.dart'; +import 'package:clock_app/settings/logic/backup.dart'; +import 'package:clock_app/settings/screens/backup_screen.dart'; import 'package:clock_app/settings/types/setting_action.dart'; import 'package:clock_app/settings/types/setting_group.dart'; -import 'package:clock_app/widgets/logic/update_widgets.dart'; +import 'package:clock_app/settings/types/setting_link.dart'; import 'package:flutter/material.dart'; -import 'package:pick_or_save/pick_or_save.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; SettingGroup backupSettingsSchema = SettingGroup( @@ -16,19 +11,13 @@ SettingGroup backupSettingsSchema = SettingGroup( (context) => AppLocalizations.of(context)!.backupSettingGroup, getDescription: (context) => AppLocalizations.of(context)!.backupSettingGroupDescription, +showExpandedView: false, icon: Icons.restore_rounded, [ - SettingGroup( - "Settings", - (context) => AppLocalizations.of(context)!.settingsTitle, - [ - SettingAction( + SettingPageLink( "Export", (context) => AppLocalizations.of(context)!.exportSettingsSetting, - (context) async { - saveBackupFile(json.encode(appSettings.valueToJson()), "settings"); - }, - searchTags: ["settings", "export", "backup", "save"], + const BackupExportScreen(), getDescription: (context) => AppLocalizations.of(context)!.exportSettingsSettingDescription, ), @@ -36,45 +25,43 @@ SettingGroup backupSettingsSchema = SettingGroup( "Import", (context) => AppLocalizations.of(context)!.importSettingsSetting, (context) async { - loadBackupFile( - (data) async { - appSettings.loadValueFromJson(json.decode(data)); - appSettings.callAllListeners(); - App.refreshTheme(context); - await appSettings.save(); - if (context.mounted) setDigitalClockWidgetData(context); - }, - ); + final data = await loadBackupFile(); + if(data == null) return; + if (context.mounted) { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => BackupImportScreen(data: data))); + } }, - searchTags: ["settings", "import", "backup", "load"], getDescription: (context) => AppLocalizations.of(context)!.importSettingsSettingDescription, ), - ], - ), + // SettingAction( + // "Export", + // (context) => AppLocalizations.of(context)!.exportSettingsSetting, + // (context) async { + // saveBackupFile(json.encode(appSettings.valueToJson()), "settings"); + // }, + // searchTags: ["settings", "export", "backup", "save"], + // getDescription: (context) => + // AppLocalizations.of(context)!.exportSettingsSettingDescription, + // ), + // SettingAction( + // "Import", + // (context) => AppLocalizations.of(context)!.importSettingsSetting, + // (context) async { + // loadBackupFile( + // (data) async { + // appSettings.loadValueFromJson(json.decode(data)); + // appSettings.callAllListeners(); + // App.refreshTheme(context); + // await appSettings.save(); + // if (context.mounted) setDigitalClockWidgetData(context); + // }, + // ); + // }, + // searchTags: ["settings", "import", "backup", "load"], + // getDescription: (context) => + // AppLocalizations.of(context)!.importSettingsSettingDescription, + // ), ], ); - -saveBackupFile(String data, String label) async { - await PickOrSave().fileSaver( - params: FileSaverParams( - saveFiles: [ - SaveFileInfo( - fileData: Uint8List.fromList(utf8.encode(data)), - fileName: "chrono_${label}_backup_${DateTime.now().toIso8601String()}", - ) - ], - )); -} - -loadBackupFile(Function(String) onSuccess) async { - List? result = await PickOrSave().filePicker( - params: FilePickerParams( - getCachedFilePath: true, - ), - ); - if (result != null && result.isNotEmpty) { - File file = File(result[0]); - onSuccess(utf8.decode(file.readAsBytesSync())); - } -} diff --git a/lib/settings/data/developer_settings_schema.dart b/lib/settings/data/developer_settings_schema.dart index e32d7770..28285fd3 100644 --- a/lib/settings/data/developer_settings_schema.dart +++ b/lib/settings/data/developer_settings_schema.dart @@ -1,18 +1,23 @@ +import 'dart:io'; + import 'package:clock_app/alarm/screens/alarm_events_screen.dart'; +import 'package:clock_app/common/data/paths.dart'; +import 'package:clock_app/common/utils/snackbar.dart'; import 'package:clock_app/settings/types/setting.dart'; +import 'package:clock_app/settings/types/setting_action.dart'; import 'package:clock_app/settings/types/setting_group.dart'; import 'package:clock_app/settings/types/setting_link.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:pick_or_save/pick_or_save.dart'; SettingGroup developerSettingsSchema = SettingGroup( "Developer Options", (context) => AppLocalizations.of(context)!.developerOptionsSettingGroup, [ - SettingGroup("Alarm", - (context) => AppLocalizations.of(context)!.alarmTitle, - [ + SettingGroup( + "Alarm", (context) => AppLocalizations.of(context)!.alarmTitle, [ SwitchSetting( "Show Instant Alarm Button", (context) => AppLocalizations.of(context)!.showIstantAlarmButtonSetting, @@ -21,9 +26,8 @@ SettingGroup developerSettingsSchema = SettingGroup( // "Show a button on the alarm screen that creates an alarm that rings one second in the future", ), ]), - SettingGroup("Logs", - (context) => AppLocalizations.of(context)!.logsSettingGroup, - [ + SettingGroup( + "Logs", (context) => AppLocalizations.of(context)!.logsSettingGroup, [ SliderSetting( "Max logs", (context) => AppLocalizations.of(context)!.maxLogsSetting, @@ -32,9 +36,39 @@ SettingGroup developerSettingsSchema = SettingGroup( 100, snapLength: 1, ), - SettingPageLink("Alarm Logs", - (context) => AppLocalizations.of(context)!.alarmLogSetting, - const AlarmEventsScreen()), + SettingPageLink( + "alarm_logs", + (context) => AppLocalizations.of(context)!.alarmLogSetting, + const AlarmEventsScreen()), + SettingAction( + "save_logs", (context) => AppLocalizations.of(context)!.saveLogs, + (context) async { + final File file = File(await getLogsFilePath()); + + if(!(await file.exists())) { + await file.create(recursive: true); + } + + await PickOrSave().fileSaver( + params: FileSaverParams( + saveFiles: [ + SaveFileInfo( + fileData: await file.readAsBytes(), + fileName: + "chrono_logs_${DateTime.now().toIso8601String().split(".")[0]}.txt", + ) + ], + )); + }), + SettingAction( + "clear_logs", (context) => AppLocalizations.of(context)!.clearLogs, + (context) async { + final File file = File(await getLogsFilePath()); + + await file.writeAsString(""); + + if(context.mounted) showSnackBar(context, "Logs cleared"); + }) ]), ], icon: Icons.code_rounded, diff --git a/lib/settings/data/general_settings_schema.dart b/lib/settings/data/general_settings_schema.dart index 1843e658..7ac16d6b 100644 --- a/lib/settings/data/general_settings_schema.dart +++ b/lib/settings/data/general_settings_schema.dart @@ -29,7 +29,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; enum TimePickerType { dial, input, spinner } -enum DurationPickerType { rings, spinner } +enum DurationPickerType { rings, spinner, numpad } SelectSettingOption _getDateSettingOption(String format) { return SelectSettingOption((context) { @@ -73,6 +73,11 @@ enum SwipeAction { switchTabs, } +enum LongPressAction { + reorder, + multiSelect, +} + final timeFormatOptions = [ SelectSettingOption( (context) => AppLocalizations.of(context)!.timeFormat12, TimeFormat.h12), @@ -107,7 +112,6 @@ SettingGroup generalSettingsSchema = SettingGroup( "Date Format", (context) => AppLocalizations.of(context)!.dateFormatSetting, dateFormatOptions, - getDescription: (context) => "How to display the dates", onChange: (context, index) async { // await HomeWidget.saveWidgetData( // "dateFormat", dateFormatOptions[index].value); @@ -118,7 +122,6 @@ SettingGroup generalSettingsSchema = SettingGroup( "Long Date Format", (context) => AppLocalizations.of(context)!.longDateFormatSetting, longDateFormatOptions, - getDescription: (context) => "How to display the dates", onChange: (context, index) async { setDigitalClockWidgetData(context); @@ -131,7 +134,6 @@ SettingGroup generalSettingsSchema = SettingGroup( "Time Format", (context) => AppLocalizations.of(context)!.timeFormatSetting, timeFormatOptions, - getDescription: (context) => "12 or 24 hour time", onChange: (context, index) async { String timeFormat = getTimeFormatString(context, timeFormatOptions[index].value); @@ -185,11 +187,17 @@ SettingGroup generalSettingsSchema = SettingGroup( (context) => AppLocalizations.of(context)!.pickerSpinner, DurationPickerType.spinner, ), + SelectSettingOption( + (context) => AppLocalizations.of(context)!.pickerNumpad, + DurationPickerType.numpad, + ), + ], searchTags: [ "duration", "rings", "time", + "numpad" "picker", "dial", "input", @@ -197,24 +205,41 @@ SettingGroup generalSettingsSchema = SettingGroup( ]), ], ), - SelectSetting( - "Swipe Action", - (context) => AppLocalizations.of(context)!.swipeActionSetting, - [ - SelectSettingOption( - (context) => AppLocalizations.of(context)!.swipActionCardAction, - SwipeAction.cardActions, - getDescription: (context) => - AppLocalizations.of(context)!.swipeActionCardActionDescription, - ), + SettingGroup("Interactions", + (context) => AppLocalizations.of(context)!.interactionsSettingGroup, [ + SelectSetting( + "Swipe Action", + (context) => AppLocalizations.of(context)!.swipeActionSetting, + [ + SelectSettingOption( + (context) => AppLocalizations.of(context)!.swipActionCardAction, + SwipeAction.cardActions, + getDescription: (context) => + AppLocalizations.of(context)!.swipeActionCardActionDescription, + ), + SelectSettingOption( + (context) => AppLocalizations.of(context)!.swipActionSwitchTabs, + SwipeAction.switchTabs, + getDescription: (context) => + AppLocalizations.of(context)!.swipeActionSwitchTabsDescription, + ) + ], + ), + SelectSetting( + "Long Press Action", + (context) => AppLocalizations.of(context)!.longPressActionSetting, + [ SelectSettingOption( - (context) => AppLocalizations.of(context)!.swipActionSwitchTabs, - SwipeAction.switchTabs, - getDescription: (context) => - AppLocalizations.of(context)!.swipeActionSwitchTabsDescription, - ) - ], - ), + (context) => AppLocalizations.of(context)!.longPressSelectAction, + LongPressAction.multiSelect, + ), + SelectSettingOption( + (context) => AppLocalizations.of(context)!.longPressReorderAction, + LongPressAction.reorder, + ), + ], + ), + ]), SettingPageLink( "Melodies", (context) => AppLocalizations.of(context)!.melodiesSetting, @@ -231,6 +256,13 @@ SettingGroup generalSettingsSchema = SettingGroup( ), SettingGroup("Reliability", (context) => AppLocalizations.of(context)!.reliabilitySettingGroup, [ + SwitchSetting( + "Show Foreground Notification", + (context) => AppLocalizations.of(context)!.showForegroundNotification, + false, + getDescription: (context) => + AppLocalizations.of(context)!.showForegroundNotificationDescription, + ), SettingAction( "Ignore Battery Optimizations", (context) => @@ -260,6 +292,8 @@ SettingGroup generalSettingsSchema = SettingGroup( .notificationPermissionAlreadyGranted) }); }, + getDescription: (context) => + AppLocalizations.of(context)!.notificationPermissionDescription, ), SettingAction( "Vendor Specific", @@ -334,26 +368,7 @@ SettingGroup generalSettingsSchema = SettingGroup( ), ], ), - SettingGroup("Animations", - (context) => AppLocalizations.of(context)!.animationSettingGroup, [ - SliderSetting( - "Animation Speed", - (context) => AppLocalizations.of(context)!.animationSpeedSetting, - 0.5, - 2, - 1, - // unit: 'm', - snapLength: 0.1, - // enableConditions: [ - // ValueCondition( - // ["Show Upcoming Alarm Notifications"], (value) => value), - // ], - ), - SwitchSetting( - "Extra Animations", - (context) => AppLocalizations.of(context)!.extraAnimationSetting, - false), - ]) + ], icon: FluxIcons.settings, getDescription: (context) => diff --git a/lib/settings/data/settings_schema.dart b/lib/settings/data/settings_schema.dart index 8accc4b6..e9706cfa 100644 --- a/lib/settings/data/settings_schema.dart +++ b/lib/settings/data/settings_schema.dart @@ -13,7 +13,9 @@ import 'package:clock_app/settings/types/setting_link.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -const int settingsSchemaVersion = 5; + +// Increment this after every schema change +const int settingsSchemaVersion = 6; SettingGroup appSettings = SettingGroup( "Settings", @@ -39,5 +41,3 @@ SettingGroup appSettings = SettingGroup( ], ); - -// Settings appSettings = Settings(settingsItems); diff --git a/lib/settings/data/timer_app_settings_schema.dart b/lib/settings/data/timer_app_settings_schema.dart index d7fbfdb4..b5a7b546 100644 --- a/lib/settings/data/timer_app_settings_schema.dart +++ b/lib/settings/data/timer_app_settings_schema.dart @@ -39,7 +39,7 @@ SettingGroup timerAppSettingsSchema = SettingGroup( ], [ SelectSettingOption( - (context) => AppLocalizations.of(context)!.dismissActionSlide, + (context) => AppLocalizations.of(context)!.dismissActionAreaButtons, NotificationAction( builder: (onDismiss, onSnooze, dismissLabel, snoozeLabel) => AreaNotificationAction( @@ -51,7 +51,7 @@ SettingGroup timerAppSettingsSchema = SettingGroup( ), ), SelectSettingOption( - (context) => AppLocalizations.of(context)!.dismissActionButtons, + (context) => AppLocalizations.of(context)!.dismissActionSlide, NotificationAction( builder: (onDismiss, onSnooze, dismissLabel, snoozeLabel) => SlideNotificationAction( @@ -63,7 +63,7 @@ SettingGroup timerAppSettingsSchema = SettingGroup( ), ), SelectSettingOption( - (context) => AppLocalizations.of(context)!.dismissActionAreaButtons, + (context) => AppLocalizations.of(context)!.dismissActionButtons, NotificationAction( builder: (onDismiss, onSnooze, dismissLabel, snoozeLabel) => ButtonsNotificationAction( diff --git a/lib/settings/logic/backup.dart b/lib/settings/logic/backup.dart new file mode 100644 index 00000000..aad286fc --- /dev/null +++ b/lib/settings/logic/backup.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:pick_or_save/pick_or_save.dart'; + +Future?> saveBackupFile(String data) async { + return await PickOrSave().fileSaver( + params: FileSaverParams( + saveFiles: [ + SaveFileInfo( + fileData: Uint8List.fromList(utf8.encode(data)), + fileName: "chrono_backup_${DateTime.now().toIso8601String().split(".")[0]}.json", + ) + ], + )); +} + +Future loadBackupFile() async { + List? result = await PickOrSave().filePicker( + params: FilePickerParams( + getCachedFilePath: true, + ), + ); + if (result != null && result.isNotEmpty) { + File file = File(result[0]); + return utf8.decode(file.readAsBytesSync()); + } + return null; +} diff --git a/lib/settings/logic/get_setting_widget.dart b/lib/settings/logic/get_setting_widget.dart index f1a70fd1..78e32542 100644 --- a/lib/settings/logic/get_setting_widget.dart +++ b/lib/settings/logic/get_setting_widget.dart @@ -32,12 +32,12 @@ List getSettingWidgets( bool isAppSettings = true, }) { bool showExtraAnimations = appSettings - .getGroup("General") + .getGroup("Appearance") .getGroup("Animations") .getSetting("Extra Animations") .value; double animationSpeed = appSettings - .getGroup("General") + .getGroup("Appearance") .getGroup("Animations") .getSetting("Animation Speed") .value; diff --git a/lib/settings/logic/initialize_settings.dart b/lib/settings/logic/initialize_settings.dart index 5852ec47..026d88f6 100644 --- a/lib/settings/logic/initialize_settings.dart +++ b/lib/settings/logic/initialize_settings.dart @@ -6,6 +6,7 @@ import 'package:clock_app/alarm/types/alarm.dart'; import 'package:clock_app/alarm/types/alarm_event.dart'; import 'package:clock_app/audio/logic/system_ringtones.dart'; import 'package:clock_app/clock/data/default_favorite_cities.dart'; +import 'package:clock_app/clock/logic/timezone_database.dart'; import 'package:clock_app/clock/types/city.dart'; import 'package:clock_app/common/data/default_tags.dart'; import 'package:clock_app/common/data/paths.dart'; @@ -26,7 +27,6 @@ import 'package:clock_app/timer/types/timer_preset.dart'; import 'package:flutter/foundation.dart'; import 'package:get_storage/get_storage.dart'; - Future _clearSettings() async { // List timers = await loadList('timers'); // List alarms = await loadList('alarms'); @@ -75,6 +75,8 @@ Future initializeStorage([bool clearSettingsOnDebug = true]) async { await initList("timer_presets", defaultTimerPresets); await initList("ringtones", await getSystemRingtones()); await initTextFile("time_format_string", "h:mm a"); + await initializeDatabases(); + // await initTextFile("", "0"); // await initTextFile("timers-sort-index", "0"); } diff --git a/lib/settings/screens/backup_screen.dart b/lib/settings/screens/backup_screen.dart new file mode 100644 index 00000000..29a48a44 --- /dev/null +++ b/lib/settings/screens/backup_screen.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; + +import 'package:clock_app/common/types/json.dart'; +import 'package:clock_app/common/utils/snackbar.dart'; +import 'package:clock_app/debug/logic/logger.dart'; +import 'package:clock_app/settings/data/backup_options.dart'; +import 'package:clock_app/settings/logic/backup.dart'; +import 'package:clock_app/settings/types/backup_option.dart'; +import 'package:clock_app/settings/widgets/settings_top_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class BackupOptionCheckBox extends StatelessWidget { + const BackupOptionCheckBox( + {super.key, required this.option, required this.onChanged}); + + final BackupOption option; + final void Function(bool?) onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Checkbox( + // checkColor: Colors.white, + // fillColor: MaterialStateProperty.resolveWith(getColor), + value: option.selected, + onChanged: onChanged, + ), + Text( + option.getName(context), + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ); + } +} + +class BackupExportScreen extends StatefulWidget { + const BackupExportScreen({ + super.key, + }); + + @override + State createState() => _BackupExportScreenState(); +} + +class _BackupExportScreenState extends State { + @override + void initState() { + for (var option in backupOptions) { + option.selected = true; + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SettingsTopBar( + actions: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: TextButton( + onPressed: () async { + try { + final backupData = {}; + for (var option in backupOptions) { + if (option.selected) { + backupData[option.key] = await option.encode(); + } + } + final result = + await saveBackupFile(json.encode(backupData)); + if (result == null) return; + if (context.mounted) { + showSnackBar(context, "Export successful!"); + } + } catch (e) { + logger.e(e.toString()); + if (context.mounted) { + showSnackBar(context, "Error exporting: ${e.toString()}", + error: true); + } + } + }, + child: + Text(AppLocalizations.of(context)!.exportSettingsSetting)), + ), + ], + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + ...backupOptions.map( + (option) => BackupOptionCheckBox( + option: option, + onChanged: (bool? value) { + setState(() { + option.selected = value ?? false; + }); + }, + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } +} + +class BackupImportScreen extends StatefulWidget { + const BackupImportScreen({ + super.key, + required this.data, + }); + + final String data; + + @override + State createState() => _BackupImportScreenState(); +} + +class _BackupImportScreenState extends State { + late final List importOptions = []; + late final Json dataJson; + + @override + void initState() { + dataJson = json.decode(widget.data); + + if (dataJson != null) { + for (var option in backupOptions) { + option.selected = true; + if (dataJson!.keys.contains(option.key)) { + importOptions.add(option); + } + } + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SettingsTopBar( + actions: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: TextButton( + onPressed: () async { + try { + if (dataJson == null) return; + for (var option in importOptions) { + if (option.selected && context.mounted) { + await option.decode(context, dataJson![option.key]); + } + } + if (context.mounted) { + showSnackBar(context, "Import successful!"); + } + } catch (e) { + logger.e(e.toString()); + if (context.mounted) { + showSnackBar(context, "Error importing: ${e.toString()}", + error: true); + } + } + }, + child: + Text(AppLocalizations.of(context)!.importSettingsSetting)), + ), + ], + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + ...importOptions.map( + (option) => BackupOptionCheckBox( + option: option, + onChanged: (bool? value) { + setState(() { + option.selected = value ?? false; + }); + }, + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } +} diff --git a/lib/settings/screens/ringtones_screen.dart b/lib/settings/screens/ringtones_screen.dart index c34549f3..591fa25c 100644 --- a/lib/settings/screens/ringtones_screen.dart +++ b/lib/settings/screens/ringtones_screen.dart @@ -75,6 +75,7 @@ class _RingtonesScreenState extends State { isDuplicateEnabled: false, placeholderText: "No melodies", reloadOnPop: true, + isSelectable: true, ), ), ], diff --git a/lib/settings/screens/settings_group_screen.dart b/lib/settings/screens/settings_group_screen.dart index e647bbcf..2a888545 100644 --- a/lib/settings/screens/settings_group_screen.dart +++ b/lib/settings/screens/settings_group_screen.dart @@ -1,9 +1,11 @@ +import 'package:clock_app/common/data/animations.dart'; import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:clock_app/settings/logic/get_setting_widget.dart'; import 'package:clock_app/settings/screens/restore_defaults_screen.dart'; import 'package:clock_app/settings/types/setting_group.dart'; import 'package:clock_app/settings/types/setting_item.dart'; import 'package:clock_app/settings/types/setting_link.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:clock_app/settings/widgets/search_setting_card.dart'; import 'package:clock_app/settings/widgets/setting_page_link_card.dart'; diff --git a/lib/settings/screens/tags_screen.dart b/lib/settings/screens/tags_screen.dart index eddf2a98..36bff289 100644 --- a/lib/settings/screens/tags_screen.dart +++ b/lib/settings/screens/tags_screen.dart @@ -72,6 +72,7 @@ class _TagsScreenState extends State { // onDeleteItem: _handleDeleteTimer, placeholderText: "No tags created", reloadOnPop: true, + isSelectable: true, ), ), ], diff --git a/lib/settings/types/backup_option.dart b/lib/settings/types/backup_option.dart new file mode 100644 index 00000000..f038bcca --- /dev/null +++ b/lib/settings/types/backup_option.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +class BackupOption { + final String Function(BuildContext context) getName; + final String key; + final Future Function() encode; + final Future Function(BuildContext context, dynamic value) decode; + bool selected = true; + + BackupOption(this.key, this.getName, + {required this.encode, required this.decode}); +} diff --git a/lib/settings/types/setting_group.dart b/lib/settings/types/setting_group.dart index 8d8987f0..75b6fee4 100644 --- a/lib/settings/types/setting_group.dart +++ b/lib/settings/types/setting_group.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:clock_app/common/data/weekdays.dart'; import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/utils/list_storage.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:clock_app/settings/types/setting.dart'; import 'package:clock_app/settings/types/setting_action.dart'; import 'package:clock_app/settings/types/setting_enable_condition.dart'; @@ -100,7 +101,12 @@ class SettingGroup extends SettingItem { } SettingGroup getGroup(String name) { - return _settingGroups.firstWhere((item) => item.name == name); + try { + return _settingGroups.firstWhere((item) => item.name == name); + } catch (e) { + logger.e("Could not find setting group $name: $e"); + rethrow; + } } Setting getSettingFromPath(List path) { @@ -130,7 +136,7 @@ class SettingGroup extends SettingItem { try { return _settingItems.firstWhere((item) => item.name == name); } catch (e) { - debugPrint("Could not find setting item $name: $e"); + logger.e("Could not find setting item $name: $e"); rethrow; } } @@ -139,7 +145,7 @@ class SettingGroup extends SettingItem { try { return _settings.firstWhere((item) => item.name == name); } catch (e) { - debugPrint("Could not find setting $name: $e"); + logger.e("Could not find setting $name: $e"); rethrow; } } @@ -226,7 +232,7 @@ class SettingGroup extends SettingItem { } } } catch (e) { - debugPrint( + logger.e( "Error migrating value in setting group ($name): ${e.toString()}"); } } @@ -234,7 +240,7 @@ class SettingGroup extends SettingItem { if (value != null) setting.loadValueFromJson(value[setting.name]); } } catch (e) { - debugPrint( + logger.e( "Error loading value from json in setting group ($name): ${e.toString()}"); } } @@ -249,7 +255,7 @@ class SettingGroup extends SettingItem { try { value = loadTextFileSync(id); } catch (e) { - debugPrint("Error loading $id: $e"); + logger.e("Error loading $id: $e"); value = GetStorage().read(id); } loadValueFromJson(json.decode(value)); diff --git a/lib/settings/widgets/list_setting_screen.dart b/lib/settings/widgets/list_setting_screen.dart index af301660..159d7e16 100644 --- a/lib/settings/widgets/list_setting_screen.dart +++ b/lib/settings/widgets/list_setting_screen.dart @@ -76,6 +76,8 @@ class _ListSettingScreenState _handleCustomizeItem(task); }, onModifyList: () => widget.onChanged(context), + isReorderable: true, + isSelectable: true, placeholderText: "No ${widget.setting.displayName(context).toLowerCase()} added yet", ), diff --git a/lib/settings/widgets/settings_top_bar.dart b/lib/settings/widgets/settings_top_bar.dart index cbb12eee..6704aaf1 100644 --- a/lib/settings/widgets/settings_top_bar.dart +++ b/lib/settings/widgets/settings_top_bar.dart @@ -13,10 +13,11 @@ class SettingsTopBar extends StatefulWidget implements PreferredSizeWidget { {super.key, this.onSearch, this.showSearch = false, - required this.title}); + this.title, this.actions}); final void Function(List settings)? onSearch; - final String title; + final List? actions; + final String? title; final bool showSearch; @override @@ -86,14 +87,15 @@ class _SettingsTopBarState extends State { ); } else { return AppTopBar( - title: Text( - widget.title, + title: widget.title != null ? Text( + widget.title!, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context).colorScheme.onBackground.withOpacity(0.6), ), - ), + ): null, actions: [ + ...?widget.actions, if (widget.showSearch) IconButton( onPressed: () { diff --git a/lib/settings/widgets/slider_setting_card.dart b/lib/settings/widgets/slider_setting_card.dart index e2be84ff..3555e960 100644 --- a/lib/settings/widgets/slider_setting_card.dart +++ b/lib/settings/widgets/slider_setting_card.dart @@ -24,6 +24,7 @@ class _SliderSettingCardState extends State { Widget build(BuildContext context) { SliderField sliderCard = SliderField( title: widget.setting.displayName(context), + description: widget.setting.displayDescription(context), value: widget.setting.value, min: widget.setting.min, max: widget.setting.max, diff --git a/lib/settings/widgets/switch_setting_card.dart b/lib/settings/widgets/switch_setting_card.dart index 7203a373..212ad546 100644 --- a/lib/settings/widgets/switch_setting_card.dart +++ b/lib/settings/widgets/switch_setting_card.dart @@ -24,6 +24,7 @@ class _SwitchSettingCardState extends State { SwitchField switchCard = SwitchField( name: widget.setting.displayName(context), value: widget.setting.value, + description: widget.setting.displayDescription(context), onChanged: (value) { setState(() { widget.setting.setValue(context, value); diff --git a/lib/stopwatch/logic/stopwatch_notification.dart b/lib/stopwatch/logic/stopwatch_notification.dart index 3ec7bb35..0ec6cf10 100644 --- a/lib/stopwatch/logic/stopwatch_notification.dart +++ b/lib/stopwatch/logic/stopwatch_notification.dart @@ -8,9 +8,9 @@ Future updateStopwatchNotification(ClockStopwatch stopwatch) async { content: NotificationContent( id: stopwatch.id, channelKey: stopwatchNotificationChannelKey, - title: 'Stopwatch', - body: - "${TimeDuration.fromMilliseconds(stopwatch.elapsedMilliseconds).toTimeString(showMilliseconds: false)} - LAP ${stopwatch.laps.length + 1}", + title: + "${TimeDuration.fromMilliseconds(stopwatch.elapsedMilliseconds).toTimeString(showMilliseconds: false)} - LAP ${stopwatch.laps.length}", + body: "Stopwatch", category: NotificationCategory.StopWatch, ), actionButtons: [ diff --git a/lib/stopwatch/screens/stopwatch_screen.dart b/lib/stopwatch/screens/stopwatch_screen.dart index 68a3bf13..2d17acea 100644 --- a/lib/stopwatch/screens/stopwatch_screen.dart +++ b/lib/stopwatch/screens/stopwatch_screen.dart @@ -39,7 +39,13 @@ class _StopwatchScreenState extends State { @override void initState() { super.initState(); - _stopwatch = loadListSync('stopwatches').first; + final stopwatches = loadListSync('stopwatches'); + if (stopwatches.isEmpty) { + _stopwatch = ClockStopwatch(); + saveList('stopwatches', [_stopwatch]); + } else { + _stopwatch = stopwatches.first; + } _showNotificationSetting = appSettings.getGroup("Stopwatch").getSetting("Show Notification"); @@ -53,14 +59,18 @@ class _StopwatchScreenState extends State { void _handleStopwatchChange() { final newList = loadListSync('stopwatches'); + _stopwatch.copyFrom(newList.first); + if (mounted) { - newList.first.laps - .where((lap) => !_stopwatch.laps.contains(lap)) - .forEach((lap) => _listController.addItem(lap)); + // // If there are any new laps, tell the listcontroller to update the ui with them + // newList.first.laps + // .where((lap) => + // !_stopwatch.laps.map((l) => l.number).contains(lap.number)) + // .forEach((lap) => _listController.addItem(lap)); + _listController.reload(_stopwatch.laps); setState(() {}); } - _stopwatch.copyFrom(newList.first); showProgressNotification(); } diff --git a/lib/stopwatch/types/stopwatch.dart b/lib/stopwatch/types/stopwatch.dart index cc3a2a68..d1b34568 100644 --- a/lib/stopwatch/types/stopwatch.dart +++ b/lib/stopwatch/types/stopwatch.dart @@ -1,10 +1,10 @@ import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/timer_state.dart'; import 'package:clock_app/common/utils/duration.dart'; +import 'package:clock_app/common/utils/id.dart'; import 'package:clock_app/common/utils/json_serialize.dart'; import 'package:clock_app/stopwatch/types/lap.dart'; import 'package:clock_app/timer/types/time_duration.dart'; -import 'package:flutter/material.dart'; // All time units are in milliseconds class ClockStopwatch extends JsonSerializable { @@ -53,7 +53,7 @@ class ClockStopwatch extends JsonSerializable { } ClockStopwatch() - : _id = UniqueKey().hashCode, + : _id = getId(), _elapsedMillisecondsOnPause = 0, _startTime = DateTime(0), _state = TimerState.stopped; @@ -62,7 +62,7 @@ class ClockStopwatch extends JsonSerializable { : _elapsedMillisecondsOnPause = 0, _startTime = DateTime(0), _state = TimerState.stopped, - _id = UniqueKey().hashCode; + _id = getId(); copyFrom(ClockStopwatch stopwatch) { _elapsedMillisecondsOnPause = stopwatch._elapsedMillisecondsOnPause; @@ -151,7 +151,7 @@ class ClockStopwatch extends JsonSerializable { ClockStopwatch.fromJson(Json json) { if (json == null) { - _id = UniqueKey().hashCode; + _id = getId(); return; } _elapsedMillisecondsOnPause = json['elapsedMillisecondsOnPause'] ?? 0; @@ -161,9 +161,9 @@ class ClockStopwatch extends JsonSerializable { _state = TimerState.values.firstWhere( (e) => e.toString() == (json['state'] ?? ''), orElse: () => TimerState.stopped); - _id = json['id'] ?? UniqueKey().hashCode; + _id = json['id'] ?? getId(); // _finishedLaps = []; - _laps = listFromString(json['laps'] ?? ''); + _laps = listFromString(json['laps'] ?? '[]'); updateFastestAndSlowestLap(); } } diff --git a/lib/system/logic/initialize_isolate.dart b/lib/system/logic/initialize_isolate.dart index cda95c0d..12035168 100644 --- a/lib/system/logic/initialize_isolate.dart +++ b/lib/system/logic/initialize_isolate.dart @@ -7,9 +7,12 @@ import 'package:clock_app/common/data/paths.dart'; import 'package:clock_app/notifications/logic/notifications.dart'; import 'package:clock_app/settings/logic/initialize_settings.dart'; import 'package:clock_app/system/data/device_info.dart'; +import 'package:flutter/widgets.dart'; Future initializeIsolate() async { DartPluginRegistrant.ensureInitialized(); + WidgetsFlutterBinding.ensureInitialized(); + await initializeAndroidInfo(); await initializeAppDataDirectory(); await initializeStorage(false); diff --git a/lib/system/logic/quick_actions.dart b/lib/system/logic/quick_actions.dart new file mode 100644 index 00000000..b2e1aeee --- /dev/null +++ b/lib/system/logic/quick_actions.dart @@ -0,0 +1,28 @@ +import 'package:clock_app/navigation/data/tabs.dart'; +import 'package:flutter/material.dart'; +import 'package:quick_actions/quick_actions.dart'; + +Future initializeQuickActions( + BuildContext context, Function(int, [String?]) setTab) async { + const QuickActions quickActions = QuickActions(); + await quickActions.initialize((shortcutType) { + if (shortcutType == 'action_add_alarm') { + setTab(getTabs(context).indexWhere((tab) => tab.id == "alarm"), "add_alarm"); + } + if (shortcutType == 'action_add_timer') { + setTab(getTabs(context).indexWhere((tab) => tab.id == "timer"), "add_timer"); + } + // More handling code... + }); + + await quickActions.setShortcutItems([ + const ShortcutItem( + type: 'action_add_alarm', + localizedTitle: 'Add alarm', + icon: 'alarm_icon'), + const ShortcutItem( + type: 'action_add_timer', + localizedTitle: 'Add timer', + icon: 'timer_icon') + ]); +} diff --git a/lib/theme/types/theme_item.dart b/lib/theme/types/theme_item.dart index e33fe42f..c8a26861 100644 --- a/lib/theme/types/theme_item.dart +++ b/lib/theme/types/theme_item.dart @@ -1,7 +1,7 @@ import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/list_item.dart'; +import 'package:clock_app/common/utils/id.dart'; import 'package:clock_app/settings/types/setting_group.dart'; -import 'package:flutter/material.dart'; abstract class ThemeItem extends CustomizableListItem { late int _id; @@ -9,12 +9,12 @@ abstract class ThemeItem extends CustomizableListItem { bool _isDefault = false; ThemeItem(SettingGroup defaultSettings, bool isDefault, [int? id]) - : _id = id ?? UniqueKey().hashCode, + : _id = id ?? getId(), _settings = defaultSettings, _isDefault = isDefault; ThemeItem.from(ThemeItem themeItem) - : _id = UniqueKey().hashCode, + : _id = getId(), _isDefault = false, _settings = themeItem.settings.copy(); @@ -45,10 +45,10 @@ abstract class ThemeItem extends CustomizableListItem { ThemeItem.fromJson(Json json, SettingGroup settings) : _settings = settings { if (json == null) { - _id = UniqueKey().hashCode; + _id = getId(); return; } - _id = json['id'] ?? UniqueKey().hashCode; + _id = json['id'] ?? getId(); _isDefault = json['isDefault'] ?? false; settings.loadValueFromJson(json['settings']); } diff --git a/lib/timer/logic/get_duration_picker.dart b/lib/timer/logic/get_duration_picker.dart index fd697e3a..672575ae 100644 --- a/lib/timer/logic/get_duration_picker.dart +++ b/lib/timer/logic/get_duration_picker.dart @@ -5,19 +5,13 @@ import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:clock_app/timer/types/time_duration.dart'; import 'package:clock_app/timer/types/timer_preset.dart'; import 'package:clock_app/timer/widgets/dial_duration_picker.dart'; +import 'package:clock_app/timer/widgets/numpad_duration_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -Widget getDurationPicker(BuildContext context, TimeDuration duration, +Widget getDurationPicker(BuildContext context,DurationPickerType type, TimeDuration duration, void Function(TimeDuration) onDurationChange, {TimerPreset? preset}) { - Orientation orientation = MediaQuery.of(context).orientation; - - DurationPickerType type = appSettings - .getGroup("General") - .getGroup("Display") - .getSetting("Duration Picker") - .value; Widget picker; @@ -83,6 +77,15 @@ Widget getDurationPicker(BuildContext context, TimeDuration duration, ), ); + case DurationPickerType.numpad: + picker = NumpadDurationPicker( + duration: duration, + onChange: (TimeDuration newDuration) { + onDurationChange(newDuration); + }, + + ); + break; } diff --git a/lib/timer/logic/timer_notification.dart b/lib/timer/logic/timer_notification.dart index fe6d9729..175cc22f 100644 --- a/lib/timer/logic/timer_notification.dart +++ b/lib/timer/logic/timer_notification.dart @@ -46,9 +46,8 @@ Future updateTimerNotification(ClockTimer timer, int count) async { content: NotificationContent( id: 2, channelKey: timerNotificationChannelKey, - title: - "${timer.label.isEmpty ? 'Timer' : timer.label}${count > 1 ? ' + ${count-1} timers' : ''}", - body: TimeDuration.fromSeconds(timer.remainingSeconds).toTimeString(), + title: "${TimeDuration.fromSeconds(timer.remainingSeconds).toTimeString()} - ${timer.label.isEmpty ? 'Timer' : timer.label}${count > 1 ? ' + ${count-1} timers' : ''}", + body: "Timer" , category: NotificationCategory.Progress, notificationLayout: NotificationLayout.ProgressBar, payload: { diff --git a/lib/timer/screens/presets_screen.dart b/lib/timer/screens/presets_screen.dart index 16fcab77..61d045ef 100644 --- a/lib/timer/screens/presets_screen.dart +++ b/lib/timer/screens/presets_screen.dart @@ -57,6 +57,7 @@ class _PresetsScreenState extends State { // onDeleteItem: _handleDeleteTimer, placeholderText: "No presets created", reloadOnPop: true, + isSelectable: true, ), ), ], diff --git a/lib/timer/screens/timer_notification_screen.dart b/lib/timer/screens/timer_notification_screen.dart index 4a291c79..07501141 100644 --- a/lib/timer/screens/timer_notification_screen.dart +++ b/lib/timer/screens/timer_notification_screen.dart @@ -1,6 +1,7 @@ import 'package:clock_app/alarm/logic/schedule_alarm.dart'; import 'package:clock_app/common/types/notification_type.dart'; import 'package:clock_app/common/widgets/card_container.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:clock_app/navigation/types/routes.dart'; import 'package:clock_app/notifications/types/fullscreen_notification_manager.dart'; import 'package:clock_app/notifications/widgets/notification_actions/slide_notification_action.dart'; @@ -67,7 +68,7 @@ class _TimerNotificationScreenState extends State { '+${getTimerById(widget.scheduleIds.last)?.addLength.floor()}:00', ); - debugPrint(e.toString()); + logger.e(e.toString()); } super.initState(); diff --git a/lib/timer/screens/timer_screen.dart b/lib/timer/screens/timer_screen.dart index 71f7e56e..2c4292be 100644 --- a/lib/timer/screens/timer_screen.dart +++ b/lib/timer/screens/timer_screen.dart @@ -1,10 +1,13 @@ import 'dart:async'; +import 'dart:isolate'; import 'package:awesome_notifications/awesome_notifications.dart'; import 'package:clock_app/common/logic/customize_screen.dart'; import 'package:clock_app/common/types/list_filter.dart'; import 'package:clock_app/common/types/picker_result.dart'; import 'package:clock_app/common/widgets/list/customize_list_item_screen.dart'; +import 'package:clock_app/debug/logic/logger.dart'; +import 'package:clock_app/navigation/types/quick_action_controller.dart'; import 'package:clock_app/notifications/data/notification_channel.dart'; import 'package:clock_app/notifications/data/update_notification_intervals.dart'; import 'package:clock_app/settings/data/settings_schema.dart'; @@ -17,6 +20,7 @@ import 'package:clock_app/timer/screens/timer_fullscreen.dart'; import 'package:clock_app/timer/widgets/timer_duration_picker.dart'; import 'package:clock_app/timer/widgets/timer_picker.dart'; import 'package:flutter/material.dart'; +// import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:great_list_view/great_list_view.dart'; import 'package:clock_app/common/widgets/fab.dart'; import 'package:clock_app/common/widgets/list/persistent_list_view.dart'; @@ -24,6 +28,85 @@ import 'package:clock_app/timer/types/timer.dart'; import 'package:clock_app/timer/widgets/timer_card.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +// Future updateForegroundTask(List timers) async { +// final runningTimers = timers.where((timer) => !timer.isStopped).toList(); +// if (runningTimers.isEmpty) { +// FlutterForegroundTask.stopService(); +// // timerNotificationInterval?.cancel(); +// return false; +// } +// // Get timer with lowest remaining time +// final timer = runningTimers +// .reduce((a, b) => a.remainingSeconds < b.remainingSeconds ? a : b); +// final count = runningTimers.length; +// +// if (await FlutterForegroundTask.isRunningService) { +// return FlutterForegroundTask.updateService( +// notificationTitle: +// "${timer.label.isEmpty ? 'Timer' : timer.label}${count > 1 ? ' + ${count - 1} timers' : ''}", +// notificationText: +// TimeDuration.fromSeconds(timer.remainingSeconds).toTimeString(), +// callback: startCallback, +// ); +// } else { +// return FlutterForegroundTask.startService( +// notificationTitle: +// "${timer.label.isEmpty ? 'Timer' : timer.label}${count > 1 ? ' + ${count - 1} timers' : ''}", +// notificationText: +// TimeDuration.fromSeconds(timer.remainingSeconds).toTimeString(), +// callback: startCallback, +// ); +// } +// } +// +// // The callback function should always be a top-level function. +// @pragma('vm:entry-point') +// void startCallback() async { +// await initializeIsolate(); +// // The setTaskHandler function must be called to handle the task in the background. +// FlutterForegroundTask.setTaskHandler(FirstTaskHandler()); +// } +// +// class FirstTaskHandler extends TaskHandler { +// // SendPort? _sendPort; +// +// // Called when the task is started. +// @override +// void onStart(DateTime timestamp, SendPort? sendPort) async {} +// +// // Called every [interval] milliseconds in [ForegroundTaskOptions]. +// @override +// void onRepeatEvent(DateTime timestamp, SendPort? sendPort) async { +// // Send data to the main isolate. +// // sendPort?.send(timestamp); +// final timers = await loadList('timers'); +// updateForegroundTask(timers); +// } +// +// // Called when the notification button on the Android platform is pressed. +// @override +// void onDestroy(DateTime timestamp, SendPort? sendPort) async {} +// +// // Called when the notification button on the Android platform is pressed. +// @override +// void onNotificationButtonPressed(String id) { +// // print('onNotificationButtonPressed >> $id'); +// } +// +// // Called when the notification itself on the Android platform is pressed. +// // +// // "android.permission.SYSTEM_ALERT_WINDOW" permission must be granted for +// // this function to be called. +// @override +// void onNotificationPressed() { +// // Note that the app will only route to "/resume-route" when it is exited so +// // it will usually be necessary to send a message through the send port to +// // signal it to restore state when the app is already started. +// FlutterForegroundTask.launchApp("/"); +// // _sendPort?.send('onNotificationPressed'); +// } +// } + typedef TimerCardBuilder = Widget Function( BuildContext context, int index, @@ -31,7 +114,9 @@ typedef TimerCardBuilder = Widget Function( ); class TimerScreen extends StatefulWidget { - const TimerScreen({super.key}); + const TimerScreen({super.key, this.actionController}); + + final QuickActionController? actionController; @override State createState() => _TimerScreenState(); @@ -48,13 +133,42 @@ class _TimerScreenState extends State { _listController.changeItems((timers) => {}); } + void _updateTimerNotification() { + // updateForegroundTask(_listController.getItems()); + if (!_showNotification.value) { + AwesomeNotifications() + .cancelNotificationsByChannelKey(timerNotificationChannelKey); + timerNotificationInterval?.cancel(); + return; + } + final runningTimers = + _listController.getItems().where((timer) => !timer.isStopped).toList(); + if (runningTimers.isEmpty) { + AwesomeNotifications() + .cancelNotificationsByChannelKey(timerNotificationChannelKey); + timerNotificationInterval?.cancel(); + return; + } + // Get timer with lowest remaining time + final timer = runningTimers + .reduce((a, b) => a.remainingSeconds < b.remainingSeconds ? a : b); + + updateTimerNotification(timer, runningTimers.length); + timerNotificationInterval?.cancel(); + timerNotificationInterval = Timer.periodic(const Duration(seconds: 1), (t) { + updateTimerNotification(timer, runningTimers.length); + }); + } + void onTimerUpdate() async { if (mounted) { _listController.reload(); setState(() {}); // _listController.changeItems((timers) => {}); } - showProgressNotification(); + _updateTimerNotification(); + + // showProgressNotification(); } @override @@ -75,7 +189,13 @@ class _TimerScreenState extends State { _showSort.addListener(update); _showNotification.addListener(update); ListenerManager.addOnChangeListener("timers", onTimerUpdate); - showProgressNotification(); + widget.actionController?.setAction((action) { + logger.i("Received action: $action"); + if (action == "add_timer") { + handleAddTimerAction(); + } + }); + // showProgressNotification(); } @override @@ -90,21 +210,27 @@ class _TimerScreenState extends State { Future _onDeleteTimer(ClockTimer deletedTimer) async { await deletedTimer.reset(); - showProgressNotification(); + _updateTimerNotification(); + + // showProgressNotification(); // _listController.deleteItem(deletedTimer); } Future _handleToggleState(ClockTimer timer) async { await timer.toggleState(); _listController.changeItems((timers) {}); - showProgressNotification(); + _updateTimerNotification(); + + // showProgressNotification(); } Future _handleStartTimer(ClockTimer timer) async { if (timer.isRunning) return; await timer.start(); _listController.changeItems((timers) {}); - showProgressNotification(); + _updateTimerNotification(); + + // showProgressNotification(); } Future _handleStartMultipleTimers(List timers) async { @@ -113,14 +239,17 @@ class _TimerScreenState extends State { await timer.start(); } _listController.changeItems((timers) {}); - showProgressNotification(); + _updateTimerNotification(); + + // showProgressNotification(); } Future _handlePauseTimer(ClockTimer timer) async { if (timer.isPaused) return; await timer.pause(); _listController.changeItems((timers) {}); - showProgressNotification(); + _updateTimerNotification(); + // showProgressNotification(); } Future _handlePauseMultipleTimers(List timers) async { @@ -129,13 +258,17 @@ class _TimerScreenState extends State { await timer.pause(); } _listController.changeItems((timers) {}); - showProgressNotification(); + _updateTimerNotification(); + + // showProgressNotification(); } Future _handleResetTimer(ClockTimer timer) async { await timer.reset(); _listController.changeItems((timers) {}); - showProgressNotification(); + _updateTimerNotification(); + + // showProgressNotification(); } Future _handleResetMultipleTimers(List timers) async { @@ -143,13 +276,17 @@ class _TimerScreenState extends State { await timer.reset(); } _listController.changeItems((timers) {}); - showProgressNotification(); + _updateTimerNotification(); + + // showProgressNotification(); } Future _handleAddTimeToTimer(ClockTimer timer) async { await timer.addTime(); _listController.changeItems((timers) {}); - showProgressNotification(); + _updateTimerNotification(); + + // showProgressNotification(); } Future _openCustomizeTimerScreen( @@ -170,6 +307,28 @@ class _TimerScreenState extends State { ); } + Future handleAddTimerAction() async { + PickerResult? pickerResult = await showTimerPicker(context); + if (pickerResult != null) { + ClockTimer timer = ClockTimer.from(pickerResult.value); + if (pickerResult.isCustomize) { + await _openCustomizeTimerScreen( + timer, + onSave: (timer) async { + await timer.start(); + _listController.addItem(timer); + }, + isNewTimer: true, + ); + } else { + await timer.start(); + _listController.addItem(timer); + } + _updateTimerNotification(); + // showProgressNotification(); + } + } + Future _handleCustomizeTimer(ClockTimer timer) async { await _openCustomizeTimerScreen(timer, onSave: (newTimer) async { // Timer id gets reset after copyFrom, so we have to cancel the old one @@ -178,34 +337,10 @@ class _TimerScreenState extends State { await timer.start(); _listController.changeItems((timers) {}); }); - showProgressNotification(); - return timer; - } + _updateTimerNotification(); - Future showProgressNotification() async { - if (!_showNotification.value) { - AwesomeNotifications() - .cancelNotificationsByChannelKey(timerNotificationChannelKey); - timerNotificationInterval?.cancel(); - return; - } - final runningTimers = - _listController.getItems().where((timer) => !timer.isStopped).toList(); - if (runningTimers.isEmpty) { - AwesomeNotifications() - .cancelNotificationsByChannelKey(timerNotificationChannelKey); - timerNotificationInterval?.cancel(); - return; - } - // Get timer with lowest remaining time - final timer = runningTimers - .reduce((a, b) => a.remainingSeconds < b.remainingSeconds ? a : b); - - updateTimerNotification(timer, runningTimers.length); - timerNotificationInterval?.cancel(); - timerNotificationInterval = Timer.periodic(const Duration(seconds: 1), (t) { - updateTimerNotification(timer, runningTimers.length); - }); + // showProgressNotification(); + return timer; } @override @@ -223,8 +358,8 @@ class _TimerScreenState extends State { onToggleState: () => _handleToggleState(timer), onPressDelete: () => _listController.deleteItem(timer), onPressDuplicate: () => _listController.duplicateItem(timer), - onPressReset: ()=> _handleResetTimer(timer), - onPressAddTime: ()=> _handleAddTimeToTimer(timer), + onPressReset: () => _handleResetTimer(timer), + onPressAddTime: () => _handleAddTimeToTimer(timer), ), onTapItem: (timer, index) async { await Navigator.push( @@ -243,6 +378,7 @@ class _TimerScreenState extends State { // _listController.changeItems((item) {}); }, onDeleteItem: _onDeleteTimer, + isSelectable: true, placeholderText: AppLocalizations.of(context)!.noTimerMessage, reloadOnPop: true, listFilters: _showFilters.value ? timerListFilters : [], @@ -271,27 +407,7 @@ class _TimerScreenState extends State { ], ), FAB( - onPressed: () async { - PickerResult? pickerResult = - await showTimerPicker(context); - if (pickerResult != null) { - ClockTimer timer = ClockTimer.from(pickerResult.value); - if (pickerResult.isCustomize) { - await _openCustomizeTimerScreen( - timer, - onSave: (timer) async { - await timer.start(); - _listController.addItem(timer); - }, - isNewTimer: true, - ); - } else { - await timer.start(); - _listController.addItem(timer); - } - showProgressNotification(); - } - }, + onPressed: handleAddTimerAction, ) ]); } diff --git a/lib/timer/types/timer.dart b/lib/timer/types/timer.dart index 1578490c..87f8ca2f 100644 --- a/lib/timer/types/timer.dart +++ b/lib/timer/types/timer.dart @@ -5,6 +5,7 @@ import 'package:clock_app/common/types/file_item.dart'; import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/notification_type.dart'; import 'package:clock_app/common/types/tag.dart'; +import 'package:clock_app/common/utils/id.dart'; import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:clock_app/settings/types/setting_group.dart'; import 'package:flutter/material.dart'; @@ -60,7 +61,10 @@ class ClockTimer extends CustomizableListItem { if (isRunning) { return math.max( _milliSecondsRemainingOnPause - - DateTime.now().difference(_startTime).toTimeDuration().inMilliseconds, + DateTime.now() + .difference(_startTime) + .toTimeDuration() + .inMilliseconds, 0); } else { return _milliSecondsRemainingOnPause; @@ -77,7 +81,7 @@ class ClockTimer extends CustomizableListItem { TimerState get state => _state; ClockTimer(this._duration) - : _id = UniqueKey().hashCode, + : _id = getId(), _currentDuration = TimeDuration.from(_duration), _milliSecondsRemainingOnPause = _duration.inSeconds * 1000, _startTime = DateTime(0), @@ -90,7 +94,7 @@ class ClockTimer extends CustomizableListItem { _startTime = DateTime(0), _state = TimerState.stopped, _settings = timer._settings.copy(), - _id = UniqueKey().hashCode; + _id = getId(); void setSetting(BuildContext context, String name, dynamic value) { _settings.getSetting(name).setValue(context, value); @@ -148,6 +152,13 @@ class ClockTimer extends CustomizableListItem { } } + Future snooze() async { + TimeDuration addedDuration = TimeDuration(minutes: addLength.floor()); + _currentDuration = addedDuration; + _milliSecondsRemainingOnPause = addedDuration.inSeconds * 1000; + await start(); + } + Future pause() async { await cancelAlarm(_id, ScheduledNotificationType.timer); _milliSecondsRemainingOnPause -= @@ -211,7 +222,7 @@ class ClockTimer extends CustomizableListItem { ClockTimer.fromJson(Json json) { if (json == null) { - _id = UniqueKey().hashCode; + _id = getId(); return; } _duration = TimeDuration.fromSeconds(json['duration'] ?? 0); @@ -223,7 +234,7 @@ class ClockTimer extends CustomizableListItem { : DateTime.now(); _state = TimerState.values.firstWhere((e) => e.toString() == json['state'], orElse: () => TimerState.stopped); - _id = json['id'] ?? UniqueKey().hashCode; + _id = json['id'] ?? getId(); _settings = SettingGroup( "Timer Settings", (context) => "Timer Settings", @@ -245,7 +256,6 @@ class ClockTimer extends CustomizableListItem { _state = other._state; _settings = other._settings.copy(); _id = other._id; - } @override diff --git a/lib/timer/types/timer_preset.dart b/lib/timer/types/timer_preset.dart index 95fe5e94..4a15b3a6 100644 --- a/lib/timer/types/timer_preset.dart +++ b/lib/timer/types/timer_preset.dart @@ -1,13 +1,13 @@ import 'package:clock_app/common/types/json.dart'; import 'package:clock_app/common/types/list_item.dart'; +import 'package:clock_app/common/utils/id.dart'; import 'package:clock_app/timer/types/time_duration.dart'; -import 'package:flutter/foundation.dart'; class TimerPreset extends ListItem { late int _id; String name = "Preset"; TimeDuration duration = const TimeDuration(minutes: 5); - TimerPreset(this.name, this.duration) : _id = UniqueKey().hashCode; + TimerPreset(this.name, this.duration) : _id = getId(); @override int get id => _id; @@ -15,7 +15,7 @@ class TimerPreset extends ListItem { bool get isDeletable => true; TimerPreset.from(TimerPreset preset) - : _id = UniqueKey().hashCode, + : _id = getId(), name = preset.name, duration = TimeDuration.from(preset.duration); @@ -28,10 +28,10 @@ class TimerPreset extends ListItem { TimerPreset.fromJson(Json json) { if (json == null) { - _id = UniqueKey().hashCode; + _id = getId(); return; } - _id = json['id'] ?? UniqueKey().hashCode; + _id = json['id'] ?? getId(); name = json['name'] ?? "Preset"; duration = TimeDuration.fromJson(json['duration']); } diff --git a/lib/timer/widgets/duration_picker.dart b/lib/timer/widgets/duration_picker.dart index ddc71e1c..226fd668 100644 --- a/lib/timer/widgets/duration_picker.dart +++ b/lib/timer/widgets/duration_picker.dart @@ -1,11 +1,12 @@ import 'package:clock_app/common/widgets/modal.dart'; +import 'package:clock_app/settings/data/general_settings_schema.dart'; +import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:clock_app/timer/logic/edit_duration_picker_mode.dart'; import 'package:clock_app/timer/logic/get_duration_picker.dart'; import 'package:clock_app/timer/types/time_duration.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - Future showDurationPicker( BuildContext context, { TimeDuration initialTimeDuration = @@ -14,6 +15,7 @@ Future showDurationPicker( }) async { final theme = Theme.of(context); final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; return showDialog( context: context, @@ -30,13 +32,19 @@ Future showDurationPicker( // Get available height and width of the build area of this widget. Make a choice depending on the size. Orientation orientation = MediaQuery.of(context).orientation; + DurationPickerType type = appSettings + .getGroup("General") + .getGroup("Display") + .getSetting("Duration Picker") + .value; + Widget title() => Row( children: [ // const SizedBox(width: 8), Text( AppLocalizations.of(context)!.durationPickerTitle, style: TimePickerTheme.of(context).helpTextStyle ?? - Theme.of(context).textTheme.labelSmall, + textTheme.labelSmall, ), const Spacer(), TextButton( @@ -44,12 +52,9 @@ Future showDurationPicker( context, () => setState(() {})), child: Text( AppLocalizations.of(context)!.timePickerModeButton, - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), + style: textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + ), ), ) ], @@ -62,6 +67,7 @@ Future showDurationPicker( Widget durationPicker() => getDurationPicker( context, + type, timeDuration, (TimeDuration newDuration) { setState(() { @@ -74,8 +80,9 @@ Future showDurationPicker( ? Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 8), - title(), + if (type != DurationPickerType.numpad) + const SizedBox(height: 8), + if (type != DurationPickerType.numpad) title(), const SizedBox(height: 16), label(), const SizedBox(height: 16), @@ -89,8 +96,9 @@ Future showDurationPicker( // mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 16), - title(), + if (type != DurationPickerType.numpad) + const SizedBox(height: 16), + if (type != DurationPickerType.numpad) title(), const SizedBox(height: 16), label(), ], diff --git a/lib/timer/widgets/numpad_duration_picker.dart b/lib/timer/widgets/numpad_duration_picker.dart new file mode 100644 index 00000000..2a617b1f --- /dev/null +++ b/lib/timer/widgets/numpad_duration_picker.dart @@ -0,0 +1,172 @@ +import 'package:clock_app/theme/text.dart'; +import 'package:clock_app/timer/types/time_duration.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class NumpadDurationPicker extends StatefulWidget { + const NumpadDurationPicker( + {super.key, required this.duration, required this.onChange}); + + final TimeDuration duration; + final void Function(TimeDuration) onChange; + + @override + State createState() => _NumpadDurationPickerState(); +} + +class _NumpadDurationPickerState extends State { + // String hours = "00"; + // String minutes = "00"; + // String seconds = "00"; + // List timeInput = ["0", "0", "0", "0", "0", "0"]; + + @override + void initState() { + super.initState(); + } + + List getTimeInput() { + final hours = widget.duration.hours.toString().padLeft(2, "0"); + final minutes = widget.duration.minutes.toString().padLeft(2, "0"); + final seconds = widget.duration.seconds.toString().padLeft(2, "0"); + return [hours[0], hours[1], minutes[0], minutes[1], seconds[0], seconds[1]]; + } + + void _addDigit(String digit, [int amount = 1]) { + setState(() { + final timeInput = getTimeInput(); + for (int i = 0; i < amount; i++) { + timeInput.removeAt(0); + timeInput.add(digit); + } + _update(timeInput); + }); + } + + void _removeDigit() { + setState(() { + final timeInput = getTimeInput(); + timeInput.removeAt(5); + timeInput.insert(0, "0"); + _update(timeInput); + }); + } + + void _update(List timeInput) { + widget.onChange(TimeDuration( + hours: int.parse("${timeInput[0]}${timeInput[1]}"), + minutes: int.parse("${timeInput[2]}${timeInput[3]}"), + seconds: int.parse("${timeInput[4]}${timeInput[5]}"), + )); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + final labelStyle = textTheme.headlineLarge + ?.copyWith(color: colorScheme.onSurface, height: 1); + final labelUnitStyle = + textTheme.headlineMedium?.copyWith(color: colorScheme.onSurface); + + double originalWidth = MediaQuery.of(context).size.width; + + final hours = widget.duration.hours.toString().padLeft(2, "0"); + final minutes = widget.duration.minutes.toString().padLeft(2, "0"); + final seconds = widget.duration.seconds.toString().padLeft(2, "0"); + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(hours, style: labelStyle), + Text("h", style: labelUnitStyle), + const SizedBox(width: 10), + Text(minutes, style: labelStyle), + Text("m", style: labelUnitStyle), + const SizedBox(width: 10), + Text(seconds, style: labelStyle), + Text("s", style: labelUnitStyle), + ], + ), + const SizedBox(height: 4), + SizedBox( + width: originalWidth * 0.76, + height: originalWidth * 1.1, + child: GridView.builder( + padding: const EdgeInsets.symmetric(vertical: 12), + shrinkWrap: true, + itemCount: 12, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 6, + mainAxisSpacing: 6, + ), + itemBuilder: (context, index) { + if (index < 9) { + return TimerButton( + label: (index + 1).toString(), + onTap: () => _addDigit((index + 1).toString()), + ); + } else if (index == 9) { + return TimerButton( + label: "00", + onTap: () { + _addDigit("0", 2); + }); + } else if (index == 10) { + return TimerButton( + label: "0", + onTap: () => _addDigit("0"), + ); + } else { + return TimerButton( + icon: Icons.backspace_outlined, + onTap: _removeDigit, + ); + } + }, + ), + ), + ], + ); + } +} + +class TimerButton extends StatelessWidget { + final String? label; + final IconData? icon; + final VoidCallback onTap; + + const TimerButton( + {super.key, this.label, required this.onTap, this.icon}); + + @override + Widget build(BuildContext context) { + ThemeData theme = Theme.of(context); + ColorScheme colorScheme = theme.colorScheme; + TextTheme textTheme = theme.textTheme; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(100), + child: Container( + decoration: BoxDecoration( + color: colorScheme.onBackground.withOpacity(0.1), + borderRadius: BorderRadius.circular(100), + ), + child: Center( + child: label != null + ? Text( + label!, + style: textTheme.titleMedium + ?.copyWith(color: colorScheme.onSurface), + ) + : icon != null + ? Icon(icon, color: colorScheme.onSurface) + : Container()), + ), + ); + } +} diff --git a/lib/timer/widgets/timer_picker.dart b/lib/timer/widgets/timer_picker.dart index a04e61a8..81e35163 100644 --- a/lib/timer/widgets/timer_picker.dart +++ b/lib/timer/widgets/timer_picker.dart @@ -2,6 +2,8 @@ import 'package:clock_app/common/types/picker_result.dart'; import 'package:clock_app/common/utils/list_storage.dart'; import 'package:clock_app/common/widgets/card_container.dart'; import 'package:clock_app/common/widgets/modal.dart'; +import 'package:clock_app/settings/data/general_settings_schema.dart'; +import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:clock_app/timer/logic/edit_duration_picker_mode.dart'; import 'package:clock_app/timer/logic/get_duration_picker.dart'; import 'package:clock_app/timer/screens/presets_screen.dart'; @@ -47,6 +49,12 @@ Future?> showTimerPicker( builder: (context) { var width = MediaQuery.of(context).size.width; + DurationPickerType type = appSettings + .getGroup("General") + .getGroup("Display") + .getSetting("Duration Picker") + .value; + Widget presetChips(double width) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -118,6 +126,7 @@ Future?> showTimerPicker( Widget durationPicker(double width) => getDurationPicker( context, + type, timer.duration, (TimeDuration newDuration) { setState(() { @@ -126,9 +135,10 @@ Future?> showTimerPicker( }, preset: selectedPreset, ); - - Widget label() => Text(timer.duration.toString(), - style: textTheme.displayMedium); + Widget label() => Text( + timer.duration.toString(), + style: textTheme.displayMedium, + ); Widget title() => Row( children: [ @@ -161,8 +171,9 @@ Future?> showTimerPicker( mainAxisSize: MainAxisSize.min, children: [ title(), - const SizedBox(height: 16), - label(), + if (type != DurationPickerType.numpad) + const SizedBox(height: 16), + if (type != DurationPickerType.numpad) label(), const SizedBox(height: 16), durationPicker(width), const SizedBox(height: 16), @@ -176,8 +187,9 @@ Future?> showTimerPicker( // mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 16), - title(), + if (type != DurationPickerType.numpad) + const SizedBox(height: 16), + if (type != DurationPickerType.numpad) title(), const SizedBox(height: 16), label(), const SizedBox(height: 16), diff --git a/lib/timer/widgets/timer_preset_picker.dart b/lib/timer/widgets/timer_preset_picker.dart index 999ae50b..2dfe4dc9 100644 --- a/lib/timer/widgets/timer_preset_picker.dart +++ b/lib/timer/widgets/timer_preset_picker.dart @@ -1,4 +1,6 @@ import 'package:clock_app/common/widgets/modal.dart'; +import 'package:clock_app/settings/data/general_settings_schema.dart'; +import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:clock_app/timer/logic/get_duration_picker.dart'; import 'package:clock_app/timer/types/time_duration.dart'; import 'package:clock_app/timer/types/timer_preset.dart'; @@ -29,6 +31,12 @@ Future showTimerPresetPicker(BuildContext context, // Get available height and width of the build area of this widget. Make a choice depending on the size. var width = MediaQuery.of(context).size.width; + DurationPickerType type = appSettings + .getGroup("General") + .getGroup("Display") + .getSetting("Duration Picker") + .value; + return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -53,7 +61,7 @@ Future showTimerPresetPicker(BuildContext context, Text(timerPreset.duration.toString(), style: textTheme.displayMedium), const SizedBox(height: 16), - getDurationPicker(context, timerPreset.duration, + getDurationPicker(context, type, timerPreset.duration, (TimeDuration newDuration) { setState(() { timerPreset.duration = newDuration; diff --git a/lib/widgets/logic/update_widgets.dart b/lib/widgets/logic/update_widgets.dart index a60ee62b..3b38fb69 100644 --- a/lib/widgets/logic/update_widgets.dart +++ b/lib/widgets/logic/update_widgets.dart @@ -1,4 +1,5 @@ import 'package:clock_app/common/utils/time_format.dart'; +import 'package:clock_app/debug/logic/logger.dart'; import 'package:clock_app/settings/data/settings_schema.dart'; import 'package:flutter/material.dart'; import 'package:home_widget/home_widget.dart'; @@ -58,7 +59,7 @@ void setDigitalClockWidgetData(BuildContext context) async { updateDigitalClockWidget(); } catch (e) { - debugPrint("Couldn't update Digital Clock Widget: $e"); + logger.e("Couldn't update Digital Clock Widget: $e"); } } diff --git a/pubspec.lock b/pubspec.lock index e8de28dc..83d91341 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -304,6 +304,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" + url: "https://pub.dev" + source: hosted + version: "4.5.0" flutter_boot_receiver: dependency: "direct main" description: @@ -320,6 +328,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" + flutter_foreground_task: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: "6ab85aadb67e68377ec14beefe7ba7ea7fb34caa" + url: "https://github.com/vicolo-dev/flutter_foreground_task" + source: git + version: "6.5.0" flutter_html: dependency: "direct main" description: @@ -397,6 +414,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" + url: "https://pub.dev" + source: hosted + version: "0.1.2" flutter_show_when_locked: dependency: "direct main" description: @@ -475,12 +500,12 @@ packages: home_widget: dependency: "direct main" description: - path: "." + path: "packages/home_widget" ref: main - resolved-ref: "5788ac45f62bef72ff44c52cbd77dc1f9258f633" + resolved-ref: "28bf6db2761467209a5244971ccb803a50a8cbb0" url: "https://github.com/AhsanSarwar45/home_widget" source: git - version: "0.5.0" + version: "0.7.0" html: dependency: transitive description: @@ -565,26 +590,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -609,6 +634,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" + url: "https://pub.dev" + source: hosted + version: "2.4.0" logging: dependency: transitive description: @@ -637,10 +670,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" move_to_background: dependency: "direct main" description: @@ -685,10 +718,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" path_provider_android: dependency: transitive description: @@ -701,10 +734,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" path_provider_linux: dependency: transitive description: @@ -849,6 +882,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0+2" + quick_actions: + dependency: "direct main" + description: + name: quick_actions + sha256: b17da113df7a7005977f64adfa58ccc49c829d3ccc6e8e770079a8c7fbf2da9e + url: "https://pub.dev" + source: hosted + version: "1.0.7" + quick_actions_android: + dependency: transitive + description: + name: quick_actions_android + sha256: "54a581491b90ff2e1be94af84a40c05e806e232184bb32afa2df57b07c4d6882" + url: "https://pub.dev" + source: hosted + version: "1.0.15" + quick_actions_ios: + dependency: transitive + description: + name: quick_actions_ios + sha256: "402596dea62a1028960b93f7651ec22be0e2a91e4fbf92a1c62d3b95f8ff95a5" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + quick_actions_platform_interface: + dependency: transitive + description: + name: quick_actions_platform_interface + sha256: "81a1e40c519bb3cacfec38b3008b13cef665a75bd270da94f40091b57f0f9236" + url: "https://pub.dev" + source: hosted + version: "1.0.6" receive_intent: dependency: "direct main" description: @@ -865,6 +930,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + url: "https://pub.dev" + source: hosted + version: "2.2.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" + source: hosted + version: "2.3.2" simple_gesture_detector: dependency: transitive description: @@ -954,10 +1075,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" timer_builder: dependency: "direct main" description: @@ -1074,10 +1195,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: "direct main" description: @@ -1143,5 +1264,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.3.1 <4.0.0" - flutter: ">=3.19.0" + dart: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 25068e71..61ae09a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,10 +2,10 @@ name: clock_app description: An alarm, clock, timer and stowatch app. publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 0.5.1+24 +version: 0.6.0-beta1+26 environment: - sdk: ">=2.18.6 <4.0.0" + sdk: '>=3.4.0 <4.0.0' dependencies: flutter: @@ -33,6 +33,8 @@ dependencies: ref: alarm_show_intent just_audio: ^0.9.31 awesome_notifications: ^0.9.3 + # awesome_notifications: + # path: "../awesome_notifications" audio_session: ^0.1.13 flutter_fgbg: ^0.3.0 move_to_background: ^1.0.2 @@ -76,9 +78,21 @@ dependencies: git: url: https://github.com/AhsanSarwar45/home_widget ref: main + path: packages/home_widget/ permission_handler: ^11.3.1 device_info_plus: ^10.1.0 - + flutter_foreground_task: + # path: "../flutter_foreground_task" + git: + url: https://github.com/vicolo-dev/flutter_foreground_task + ref: master + logger: ^2.4.0 + flutter_animate: ^4.5.0 + quick_actions: ^1.0.7 + # animated_reorderable_list: ^1.1.1 + # animated_reorderable_list: + # path: "../animated_reorderable_list" + # dev_dependencies: flutter_test: