diff --git a/lib/App.dart b/lib/App.dart index e540f0df..9c127e4b 100644 --- a/lib/App.dart +++ b/lib/App.dart @@ -13,6 +13,17 @@ import 'package:locus/widgets/DismissKeyboard.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import 'package:provider/provider.dart'; +import 'app_wrappers/CheckViewAlarmsLive.dart'; +import 'app_wrappers/HandleNotifications.dart'; +import 'app_wrappers/InitCurrentLocationFromSettings.dart'; +import 'app_wrappers/ManageQuickActions.dart'; +import 'app_wrappers/PublishTaskPositionsOnUpdate.dart'; +import 'app_wrappers/RegisterBackgroundListeners.dart'; +import 'app_wrappers/ShowUpdateDialog.dart'; +import 'app_wrappers/UniLinksHandler.dart'; +import 'app_wrappers/UpdateLastLocationToSettings.dart'; +import 'app_wrappers/UpdateLocaleToSettings.dart'; +import 'app_wrappers/UpdateLocationHistory.dart'; import 'constants/themes.dart'; ColorScheme createColorScheme( @@ -184,6 +195,22 @@ class App extends StatelessWidget { ), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => Stack( + children: [ + const UpdateLocationHistory(), + const UniLinksHandler(), + const UpdateLastLocationToSettings(), + const RegisterBackgroundListeners(), + const UpdateLocaleToSettings(), + const HandleNotifications(), + const CheckViewAlarmsLive(), + const ManageQuickActions(), + const InitCurrentLocationFromSettings(), + const ShowUpdateDialog(), + const PublishTaskPositionsOnUpdate(), + if (child != null) child, + ], + ), onGenerateRoute: (routeSettings) { final screen = (() { if (settings.getRequireBiometricAuthenticationOnStart()) { diff --git a/lib/app_wrappers/CheckViewAlarmsLive.dart b/lib/app_wrappers/CheckViewAlarmsLive.dart new file mode 100644 index 00000000..8a1e55be --- /dev/null +++ b/lib/app_wrappers/CheckViewAlarmsLive.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:locus/services/current_location_service.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/manager_service/helpers.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:provider/provider.dart'; + +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +/// Checks view alarms while the app is in use +class CheckViewAlarmsLive extends StatefulWidget { + const CheckViewAlarmsLive({super.key}); + + @override + State createState() => _CheckViewAlarmsLiveState(); +} + +class _CheckViewAlarmsLiveState extends State { + late final StreamSubscription _subscription; + + @override + void initState() { + super.initState(); + + final currentLocation = context.read(); + _subscription = currentLocation.stream.listen((position) async { + final l10n = AppLocalizations.of(context); + final viewService = context.read(); + final userLocation = await LocationPointService.fromPosition(position); + + if (!mounted) { + return; + } + + checkViewAlarms( + l10n: l10n, + viewService: viewService, + userLocation: userLocation, + ); + }); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/lib/app_wrappers/HandleNotifications.dart b/lib/app_wrappers/HandleNotifications.dart new file mode 100644 index 00000000..b023cc45 --- /dev/null +++ b/lib/app_wrappers/HandleNotifications.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/notifications.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/main.dart'; +import 'package:locus/screens/ViewDetailsScreen.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/PageRoute.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; + +class HandleNotifications extends StatefulWidget { + const HandleNotifications({super.key}); + + @override + State createState() => _HandleNotificationsState(); +} + +class _HandleNotificationsState extends State { + late final StreamSubscription _subscription; + + @override + void initState() { + super.initState(); + _subscription = + selectedNotificationsStream.stream.listen(_handleNotification); + } + + @override + void dispose() { + _subscription.cancel(); + + super.dispose(); + } + + void _handleNotification(final NotificationResponse notification) { + FlutterLogs.logInfo( + LOG_TAG, + "Notification", + "Notification received: ${notification.payload}", + ); + + if (notification.payload == null) { + FlutterLogs.logWarn( + LOG_TAG, + "Notification", + "----> but no payload, so ignoring.", + ); + return; + } + + try { + final data = jsonDecode(notification.payload!); + final type = NotificationActionType.values[data["type"]]; + + FlutterLogs.logInfo( + LOG_TAG, + "Notification", + "Type is $type." + ); + + switch (type) { + case NotificationActionType.openTaskView: + final viewService = context.read(); + + Navigator.of(context).push( + NativePageRoute( + context: context, + builder: (_) => + ViewDetailsScreen( + view: viewService.getViewById(data["taskViewID"]), + ), + ), + ); + break; + case NotificationActionType.openPermissionsSettings: + openAppSettings(); + + break; + } + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Notification", + "Error handling notification: $error", + ); + } + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/app_wrappers/InitCurrentLocationFromSettings.dart b/lib/app_wrappers/InitCurrentLocationFromSettings.dart new file mode 100644 index 00000000..7f1770ec --- /dev/null +++ b/lib/app_wrappers/InitCurrentLocationFromSettings.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:locus/services/current_location_service.dart'; +import 'package:locus/services/settings_service/SettingsMapLocation.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:provider/provider.dart'; + +class InitCurrentLocationFromSettings extends StatefulWidget { + const InitCurrentLocationFromSettings({super.key}); + + @override + State createState() => + _InitCurrentLocationFromSettingsState(); +} + +class _InitCurrentLocationFromSettingsState + extends State { + late final CurrentLocationService _currentLocation; + + @override + void initState() { + super.initState(); + + _currentLocation = context.read(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final settings = context.read(); + final lastLocation = settings.getLastMapLocation(); + + if (lastLocation != null) { + _setLocation(lastLocation); + } + }); + } + + void _setLocation(final SettingsLastMapLocation rawLocation) { + final position = rawLocation.asPosition(); + + _currentLocation.updateCurrentPosition(position); + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/app_wrappers/InitLocationFromSettings.dart b/lib/app_wrappers/InitLocationFromSettings.dart new file mode 100644 index 00000000..3851604b --- /dev/null +++ b/lib/app_wrappers/InitLocationFromSettings.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class InitLocationFromSettings extends StatelessWidget { + const InitLocationFromSettings({super.key}); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/lib/app_wrappers/ManageQuickActions.dart b/lib/app_wrappers/ManageQuickActions.dart new file mode 100644 index 00000000..9e37152a --- /dev/null +++ b/lib/app_wrappers/ManageQuickActions.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/screens/ShortcutScreen.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/utils/PageRoute.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import 'package:provider/provider.dart'; +import 'package:quick_actions/quick_actions.dart'; + +enum ShortcutType { + createOneHour, + shareNow, + stopAllTasks, +} + +const actions = QuickActions(); + +const SHORTCUT_TYPE_ICON_MAP = { + ShortcutType.createOneHour: Icons.timelapse_rounded, + ShortcutType.shareNow: Icons.location_on, + ShortcutType.stopAllTasks: Icons.stop_circle_rounded, +}; + +class ManageQuickActions extends StatefulWidget { + const ManageQuickActions({super.key}); + + @override + State createState() => _ManageQuickActionsState(); +} + +class _ManageQuickActionsState extends State { + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final settings = context.read(); + + if (settings.userHasSeenWelcomeScreen) { + _registerActions(); + } else { + _removeActions(); + } + }); + } + + void _registerActions() { + final l10n = AppLocalizations.of(context); + + FlutterLogs.logInfo( + LOG_TAG, + "Quick Actions", + "Initializing quick actions...", + ); + + actions.initialize((type) async { + FlutterLogs.logInfo( + LOG_TAG, "Quick Actions", "Quick action $type triggered."); + + if (isCupertino(context)) { + showCupertinoModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => ShortcutScreen( + type: ShortcutType.values.firstWhere( + (element) => element.name == type, + ), + ), + ); + } else { + Navigator.push( + context, + NativePageRoute( + context: context, + builder: (_) => ShortcutScreen( + type: ShortcutType.values.firstWhere( + (element) => element.name == type, + ), + ), + ), + ); + } + }); + + actions.setShortcutItems([ + ShortcutItem( + type: ShortcutType.createOneHour.name, + localizedTitle: l10n.quickActions_createOneHour, + icon: "ic_quick_actions_create_one_hour_task", + ), + ShortcutItem( + type: ShortcutType.shareNow.name, + localizedTitle: l10n.quickActions_shareNow, + icon: "ic_quick_actions_share_now", + ), + ShortcutItem( + type: ShortcutType.stopAllTasks.name, + localizedTitle: l10n.quickActions_stopTasks, + icon: "ic_quick_actions_stop_all_tasks", + ), + ]); + + FlutterLogs.logInfo( + LOG_TAG, + "Quick Actions", + "Quick actions initialized successfully!", + ); + } + + void _removeActions() { + FlutterLogs.logInfo( + LOG_TAG, + "Quick Actions", + "Removing quick actions...", + ); + + actions.clearShortcutItems(); + + FlutterLogs.logInfo( + LOG_TAG, + "Quick Actions", + "Quick actions removed successfully!", + ); + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/app_wrappers/PublishTaskPositionsOnUpdate.dart b/lib/app_wrappers/PublishTaskPositionsOnUpdate.dart new file mode 100644 index 00000000..efec59fe --- /dev/null +++ b/lib/app_wrappers/PublishTaskPositionsOnUpdate.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:locus/services/current_location_service.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/task_service/index.dart'; +import 'package:provider/provider.dart'; + +class PublishTaskPositionsOnUpdate extends StatefulWidget { + const PublishTaskPositionsOnUpdate({super.key}); + + @override + State createState() => + _PublishTaskPositionsOnUpdateState(); +} + +class _PublishTaskPositionsOnUpdateState + extends State { + late final CurrentLocationService _currentLocation; + late final StreamSubscription _stream; + + @override + void initState() { + super.initState(); + + _currentLocation = context.read(); + + _stream = _currentLocation.stream.listen((position) async { + final taskService = context.read(); + + final runningTasks = await taskService.getRunningTasks().toList(); + + if (runningTasks.isEmpty) { + return; + } + + final locationData = await LocationPointService.fromPosition(position); + + for (final task in runningTasks) { + await task.publisher.publishOutstandingPositions(); + await task.publisher.publishLocation( + locationData.copyWithDifferentId(), + ); + } + }); + } + + @override + void dispose() { + _stream.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/app_wrappers/RegisterBackgroundListeners.dart b/lib/app_wrappers/RegisterBackgroundListeners.dart new file mode 100644 index 00000000..e8a5aab1 --- /dev/null +++ b/lib/app_wrappers/RegisterBackgroundListeners.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/services/manager_service/background_locator.dart'; +import 'package:locus/services/manager_service/index.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/services/task_service/index.dart'; +import 'package:provider/provider.dart'; + +class RegisterBackgroundListeners extends StatefulWidget { + const RegisterBackgroundListeners({super.key}); + + @override + State createState() => + _RegisterBackgroundListenersState(); +} + +class _RegisterBackgroundListenersState + extends State { + late final SettingsService _settings; + late final TaskService _taskService; + + @override + void initState() { + super.initState(); + + _settings = context.read(); + _taskService = context.read(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _settings.addListener(_updateListeners); + _taskService.addListener(_updateListeners); + + _updateListeners(); + }); + } + + @override + void dispose() { + _settings.removeListener(_updateListeners); + _taskService.removeListener(_updateListeners); + + super.dispose(); + } + + void _updateListeners() async { + if (!_settings.userHasSeenWelcomeScreen) { + return; + } + + FlutterLogs.logInfo( + LOG_TAG, + "Register Background Listeners", + "Updating listeners...", + ); + final shouldCheckLocation = (await _taskService.hasRunningTasks()) || + (await _taskService.hasScheduledTasks()); + + if (!shouldCheckLocation) { + FlutterLogs.logInfo(LOG_TAG, "Register Background Listeners", + "---> but no tasks are running or scheduled, so unregistering everything."); + + await removeBackgroundLocator(); + removeBackgroundFetch(); + + return; + } + + FlutterLogs.logInfo( + LOG_TAG, + "Register Background Listeners", + "Registering BackgroundFetch", + ); + + // Always use background fetch as a fallback + await configureBackgroundFetch(); + registerBackgroundFetch(); + + if (_settings.useRealtimeUpdates) { + FlutterLogs.logInfo( + LOG_TAG, + "Register Background Listeners", + "Should use realtime updates; Registering background locator", + ); + + await configureBackgroundLocator(); + await registerBackgroundLocator(context); + } else { + FlutterLogs.logInfo( + LOG_TAG, + "Register Background Listeners", + "Not using realtime updates; Removing background locator", + ); + removeBackgroundLocator(); + } + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/app_wrappers/ShowUpdateDialog.dart b/lib/app_wrappers/ShowUpdateDialog.dart new file mode 100644 index 00000000..f5905b39 --- /dev/null +++ b/lib/app_wrappers/ShowUpdateDialog.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/services/app_update_service.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; + +class ShowUpdateDialog extends StatefulWidget { + const ShowUpdateDialog({super.key}); + + @override + State createState() => _ShowUpdateDialogState(); +} + +class _ShowUpdateDialogState extends State { + late final AppUpdateService _appUpdateService; + + @override + void initState() { + super.initState(); + + _appUpdateService = context.read(); + } + + void _showDialogIfRequired() async { + final l10n = AppLocalizations.of(context); + + if (_appUpdateService.shouldShowDialogue() && + !_appUpdateService.hasShownDialogue && + mounted) { + await showPlatformDialog( + context: context, + barrierDismissible: false, + material: MaterialDialogData( + barrierColor: Colors.black, + ), + builder: (context) => PlatformAlertDialog( + title: Text(l10n.updateAvailable_android_title), + content: Text(l10n.updateAvailable_android_description), + actions: [ + PlatformDialogAction( + onPressed: () { + Navigator.of(context).pop(); + }, + material: (context, _) => MaterialDialogActionData( + icon: const Icon(Icons.watch_later_rounded)), + child: Text(l10n.updateAvailable_android_remindLater), + ), + PlatformDialogAction( + onPressed: () { + _appUpdateService.doNotShowDialogueAgain(); + + Navigator.of(context).pop(); + }, + material: (context, _) => + MaterialDialogActionData(icon: const Icon(Icons.block)), + child: Text(l10n.updateAvailable_android_ignore), + ), + PlatformDialogAction( + onPressed: _appUpdateService.openStoreForUpdate, + material: (context, _) => + MaterialDialogActionData(icon: const Icon(Icons.download)), + child: Text(l10n.updateAvailable_android_download), + ), + ], + ), + ); + + _appUpdateService.setHasShownDialogue(); + } + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/app_wrappers/UniLinksHandler.dart b/lib/app_wrappers/UniLinksHandler.dart new file mode 100644 index 00000000..8022c19a --- /dev/null +++ b/lib/app_wrappers/UniLinksHandler.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/screens/ImportTaskSheet.dart'; +import 'package:uni_links/uni_links.dart'; + +// l10n +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../constants/values.dart'; + +class UniLinksHandler extends StatefulWidget { + const UniLinksHandler({super.key}); + + @override + State createState() => _UniLinksHandlerState(); +} + +class _UniLinksHandlerState extends State { + late final StreamSubscription _stream; + + @override + void initState() { + super.initState(); + + FlutterLogs.logInfo( + LOG_TAG, + "Uni Links", + "Initiating uni links...", + ); + _stream = linkStream.listen((final String? link) { + if (link != null) { + _importLink(link); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _initInitialLink(); + }); + } + + @override + void dispose() { + _stream.cancel(); + super.dispose(); + } + + Future _importLink(final String url) { + FlutterLogs.logInfo( + LOG_TAG, + "Uni Links", + "Importing new uni link", + ); + + return showPlatformModalSheet( + context: context, + material: MaterialModalSheetData( + isScrollControlled: true, + isDismissible: true, + backgroundColor: Colors.transparent, + ), + builder: (context) => ImportTaskSheet(initialURL: url), + ); + } + + void _initInitialLink() async { + final l10n = AppLocalizations.of(context); + + FlutterLogs.logInfo( + LOG_TAG, + "Uni Links", + "Checking initial link", + ); + + try { + // Only fired when the app was in background + final initialLink = await getInitialLink(); + + if (initialLink == null) { + FlutterLogs.logInfo( + LOG_TAG, + "Uni Links", + "----> but it is null, so skipping it.", + ); + return; + } + + await _importLink(initialLink); + } on PlatformException catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Uni Links", + "Error initializing uni links: $error", + ); + + showPlatformDialog( + context: context, + builder: (_) => + PlatformAlertDialog( + title: Text(l10n.uniLinksOpenError), + content: Text(error.message ?? l10n.unknownError), + actions: [ + PlatformDialogAction( + child: Text(l10n.closeNeutralAction), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/app_wrappers/UpdateLastLocationToSettings.dart b/lib/app_wrappers/UpdateLastLocationToSettings.dart new file mode 100644 index 00000000..f60899fb --- /dev/null +++ b/lib/app_wrappers/UpdateLastLocationToSettings.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:locus/services/current_location_service.dart'; +import 'package:locus/services/settings_service/SettingsMapLocation.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:provider/provider.dart'; + +class UpdateLastLocationToSettings extends StatefulWidget { + const UpdateLastLocationToSettings({super.key}); + + @override + State createState() => + _UpdateLastLocationToSettingsState(); +} + +class _UpdateLastLocationToSettingsState + extends State { + late final CurrentLocationService _currentLocation; + + @override + void initState() { + super.initState(); + + _currentLocation = context.read(); + + _currentLocation.addListener(_handleLocationChange); + } + + void _handleLocationChange() async { + final settings = context.read(); + final position = _currentLocation.currentPosition; + + if (position == null) { + return; + } + + settings.setLastMapLocation( + SettingsLastMapLocation( + latitude: position.latitude, + longitude: position.longitude, + accuracy: position.accuracy, + ), + ); + await settings.save(); + } + + @override + void dispose() { + _currentLocation.removeListener(_handleLocationChange); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/app_wrappers/UpdateLocaleToSettings.dart b/lib/app_wrappers/UpdateLocaleToSettings.dart new file mode 100644 index 00000000..d67a26cb --- /dev/null +++ b/lib/app_wrappers/UpdateLocaleToSettings.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class UpdateLocaleToSettings extends StatefulWidget { + const UpdateLocaleToSettings({super.key}); + + @override + State createState() => _UpdateLocaleToSettingsState(); +} + +class _UpdateLocaleToSettingsState extends State { + late final SettingsService _settings; + + @override + void initState() { + super.initState(); + + _settings = context.read(); + _settings.addListener(_updateLocale); + } + + @override + void dispose() { + _settings.removeListener(_updateLocale); + super.dispose(); + } + + void _updateLocale() async { + _settings.localeName = AppLocalizations + .of(context) + .localeName; + + await _settings.save(); + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/app_wrappers/UpdateLocationHistory.dart b/lib/app_wrappers/UpdateLocationHistory.dart new file mode 100644 index 00000000..67211fa6 --- /dev/null +++ b/lib/app_wrappers/UpdateLocationHistory.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:locus/services/current_location_service.dart'; +import 'package:locus/services/location_history_service/index.dart'; +import 'package:provider/provider.dart'; + +/// Makes sure that the [LocationHistory] is updated with the current location +/// from the [CurrentLocationService]. +class UpdateLocationHistory extends StatefulWidget { + const UpdateLocationHistory({super.key}); + + @override + State createState() => _UpdateLocationHistoryState(); +} + +class _UpdateLocationHistoryState extends State { + late final StreamSubscription _subscription; + + @override + void initState() { + super.initState(); + + final currentLocation = context.read(); + final locationHistory = context.read(); + _subscription = currentLocation.stream.listen(locationHistory.add); + } + + @override + void dispose() { + _subscription.cancel(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/constants/values.dart b/lib/constants/values.dart index bde12057..c7b6cd67 100644 --- a/lib/constants/values.dart +++ b/lib/constants/values.dart @@ -5,7 +5,7 @@ const TRANSLATION_HELP_URL = "https://github.com/Myzel394/locus"; const DONATION_URL = "https://github.com/Myzel394/locus"; const APK_RELEASES_URL = "https://github.com/Myzel394/locus/releases"; -const BACKGROUND_LOCATION_UPDATES_MINIMUM_DISTANCE_FILTER = 50; +const BACKGROUND_LOCATION_UPDATES_MINIMUM_DISTANCE_FILTER = 30; const LOCATION_FETCH_TIME_LIMIT = Duration(minutes: 5); const LOCATION_INTERVAL = Duration(minutes: 1); diff --git a/lib/init_quick_actions.dart b/lib/init_quick_actions.dart deleted file mode 100644 index 19d9a9e6..00000000 --- a/lib/init_quick_actions.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_logs/flutter_logs.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:locus/constants/values.dart'; -import 'package:locus/screens/ShortcutScreen.dart'; -import 'package:locus/utils/PageRoute.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; -import 'package:quick_actions/quick_actions.dart'; - -enum ShortcutType { - createOneHour, - shareNow, - stopAllTasks, -} - -const actions = QuickActions(); - -const SHORTCUT_TYPE_ICON_MAP = { - ShortcutType.createOneHour: Icons.timelapse_rounded, - ShortcutType.shareNow: Icons.location_on, - ShortcutType.stopAllTasks: Icons.stop_circle_rounded, -}; - -void initQuickActions(final BuildContext context) { - final l10n = AppLocalizations.of(context); - - FlutterLogs.logInfo( - LOG_TAG, "Quick Actions", "Initializing quick actions..."); - - actions.initialize((type) async { - FlutterLogs.logInfo( - LOG_TAG, "Quick Actions", "Quick action $type triggered."); - - if (isCupertino(context)) { - showCupertinoModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (_) => ShortcutScreen( - type: ShortcutType.values.firstWhere( - (element) => element.name == type, - ), - ), - ); - } else { - Navigator.push( - context, - NativePageRoute( - context: context, - builder: (_) => ShortcutScreen( - type: ShortcutType.values.firstWhere( - (element) => element.name == type, - ), - ), - ), - ); - } - }); - - actions.setShortcutItems([ - ShortcutItem( - type: ShortcutType.createOneHour.name, - localizedTitle: l10n.quickActions_createOneHour, - icon: "ic_quick_actions_create_one_hour_task", - ), - ShortcutItem( - type: ShortcutType.shareNow.name, - localizedTitle: l10n.quickActions_shareNow, - icon: "ic_quick_actions_share_now", - ), - ShortcutItem( - type: ShortcutType.stopAllTasks.name, - localizedTitle: l10n.quickActions_stopTasks, - icon: "ic_quick_actions_stop_all_tasks", - ), - ]); - - FlutterLogs.logInfo( - LOG_TAG, "Quick Actions", "Quick actions initialized successfully!"); -} diff --git a/lib/main.dart b/lib/main.dart index cb007505..156c6f84 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,12 +7,22 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_logs/flutter_logs.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:locus/App.dart'; -import 'package:locus/api/get-relays-meta.dart'; +import 'package:locus/app_wrappers/CheckViewAlarmsLive.dart'; +import 'package:locus/app_wrappers/HandleNotifications.dart'; +import 'package:locus/app_wrappers/InitCurrentLocationFromSettings.dart'; +import 'package:locus/app_wrappers/UpdateLocationHistory.dart'; +import 'package:locus/app_wrappers/PublishTaskPositionsOnUpdate.dart'; +import 'package:locus/app_wrappers/RegisterBackgroundListeners.dart'; +import 'package:locus/app_wrappers/ManageQuickActions.dart'; +import 'package:locus/app_wrappers/ShowUpdateDialog.dart'; +import 'package:locus/app_wrappers/UniLinksHandler.dart'; +import 'package:locus/app_wrappers/UpdateLastLocationToSettings.dart'; +import 'package:locus/app_wrappers/UpdateLocaleToSettings.dart'; import 'package:locus/screens/locations_overview_screen_widgets/LocationFetchers.dart'; import 'package:locus/services/app_update_service.dart'; import 'package:locus/services/current_location_service.dart'; +import 'package:locus/services/location_history_service/index.dart'; import 'package:locus/services/log_service.dart'; -import 'package:locus/services/manager_service/background_fetch.dart'; import 'package:locus/services/settings_service/index.dart'; import 'package:locus/services/task_service/index.dart'; import 'package:locus/services/view_service/index.dart'; @@ -67,12 +77,14 @@ void main() async { SettingsService.restore(), LogService.restore(), AppUpdateService.restore(), + LocationHistory.restore(), ]); final TaskService taskService = futures[0]; final ViewService viewService = futures[1]; final SettingsService settingsService = futures[2]; final LogService logService = futures[3]; final AppUpdateService appUpdateService = futures[4]; + final LocationHistory locationHistory = futures[5]; await logService.deleteOldLogs(); @@ -91,6 +103,7 @@ void main() async { create: (_) => LocationFetchers(viewService.views)), ChangeNotifierProvider( create: (_) => CurrentLocationService()), + ChangeNotifierProvider(create: (_) => locationHistory), ], child: const App(), ), diff --git a/lib/screens/LocationsOverviewScreen.dart b/lib/screens/LocationsOverviewScreen.dart index d41315ae..cd926805 100644 --- a/lib/screens/LocationsOverviewScreen.dart +++ b/lib/screens/LocationsOverviewScreen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:core'; import 'dart:io'; import 'dart:math'; @@ -19,7 +18,6 @@ import 'package:flutter_map_marker_popup/flutter_map_marker_popup.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; -import 'package:locus/api/get-relays-meta.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/screens/ImportTaskSheet.dart'; import 'package:locus/screens/SettingsScreen.dart'; @@ -30,9 +28,7 @@ import 'package:locus/screens/locations_overview_screen_widgets/OutOfBoundMarker import 'package:locus/screens/locations_overview_screen_widgets/ShareLocationSheet.dart'; import 'package:locus/screens/locations_overview_screen_widgets/ViewLocationPopup.dart'; import 'package:locus/services/current_location_service.dart'; -import 'package:locus/services/manager_service/background_locator.dart'; -import 'package:locus/services/manager_service/helpers.dart'; -import 'package:locus/services/settings_service/SettingsMapLocation.dart'; +import 'package:locus/services/location_history_service/index.dart'; import 'package:locus/services/settings_service/index.dart'; import 'package:locus/services/task_service/index.dart'; import 'package:locus/services/view_service/index.dart'; @@ -52,23 +48,15 @@ import 'package:locus/widgets/MapActionsContainer.dart'; import 'package:locus/widgets/Paper.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; -import 'package:uni_links/uni_links.dart'; -import '../constants/notifications.dart'; import '../constants/values.dart'; -import '../init_quick_actions.dart'; -import '../main.dart'; import '../services/app_update_service.dart'; import '../services/location_point_service.dart'; import '../services/log_service.dart'; -import '../services/manager_service/background_fetch.dart'; -import '../utils/PageRoute.dart'; import '../utils/color.dart'; import '../utils/platform.dart'; import '../utils/theme.dart'; -import 'ViewDetailsScreen.dart'; import 'locations_overview_screen_widgets/ViewDetailsSheet.dart'; // After this threshold, locations will not be merged together anymore @@ -111,7 +99,6 @@ class _LocationsOverviewScreenState extends State LocationPointService? visibleLocation; Position? lastPosition; - StreamSubscription? _uniLinksStream; Timer? _viewsAlarmCheckerTimer; LocationStatus locationStatus = LocationStatus.stale; @@ -137,38 +124,29 @@ class _LocationsOverviewScreenState extends State final viewService = context.read(); final logService = context.read(); final settings = context.read(); - final appUpdateService = context.read(); final locationFetchers = context.read(); _handleViewAlarmChecker(); - _handleNotifications(); locationFetchers.addAll(viewService.views); - settings.addListener(_updateBackgroundListeners); - taskService.addListener(_updateBackgroundListeners); locationFetchers.addLocationUpdatesListener(_rebuild); WidgetsBinding.instance ..addObserver(this) ..addPostFrameCallback((_) { _setLocationFromSettings(); - initQuickActions(context); - _initUniLinks(); - _updateLocaleToSettings(); - _updateBackgroundListeners(); - _showUpdateDialogIfRequired(); - _initLiveLocationUpdate(); locationFetchers.fetchPreviewLocations(); taskService.checkup(logService); - appUpdateService.addListener(_rebuild); viewService.addListener(_handleViewServiceChange); Geolocator.checkPermission().then((status) { if ({LocationPermission.always, LocationPermission.whileInUse} .contains(status)) { + _initLiveLocationUpdate(); + updateCurrentPosition( askPermissions: false, showErrorMessage: false, @@ -199,20 +177,17 @@ class _LocationsOverviewScreenState extends State @override dispose() { - final appUpdateService = context.read(); final locationFetchers = context.read(); flutterMapController?.dispose(); _viewsAlarmCheckerTimer?.cancel(); - _uniLinksStream?.cancel(); mapEventStream.close(); _removeLiveLocationUpdate(); WidgetsBinding.instance.removeObserver(this); - appUpdateService.removeListener(_rebuild); locationFetchers.removeLocationUpdatesListener(_rebuild); super.dispose(); @@ -243,10 +218,9 @@ class _LocationsOverviewScreenState extends State void _setLocationFromSettings() async { final settings = context.read(); - final rawPosition = settings.getLastMapLocation(); - final currentLocation = context.read(); + final rawLocation = settings.getLastMapLocation(); - if (rawPosition == null) { + if (rawLocation == null) { return; } @@ -254,26 +228,11 @@ class _LocationsOverviewScreenState extends State locationStatus = LocationStatus.stale; }); - final position = Position( - latitude: rawPosition.latitude, - longitude: rawPosition.longitude, - accuracy: rawPosition.accuracy, - altitudeAccuracy: 0.0, - headingAccuracy: 0.0, - timestamp: DateTime.now(), - altitude: 0, - heading: 0, - speed: 0, - speedAccuracy: 0, - ); - - await _animateToPosition(position); - currentLocation.updateCurrentPosition(position); + await _animateToPosition(rawLocation.asPosition()); } List mergeLocationsIfRequired( - final List locations, - ) { + final List locations,) { if (locations.isEmpty) { return locations; } @@ -310,7 +269,7 @@ class _LocationsOverviewScreenState extends State notificationText: l10n.backgroundLocationFetch_text, notificationTitle: l10n.backgroundLocationFetch_title, notificationIcon: - const AndroidResource(name: "ic_quick_actions_share_now"), + const AndroidResource(name: "ic_quick_actions_share_now"), ), ); } else if (isPlatformApple()) { @@ -332,54 +291,6 @@ class _LocationsOverviewScreenState extends State ); } - void _updateLocationToSettings(final Position position) async { - final settings = context.read(); - - settings.setLastMapLocation( - SettingsLastMapLocation( - latitude: position.latitude, - longitude: position.longitude, - accuracy: position.accuracy, - ), - ); - await settings.save(); - } - - void _updateBackgroundListeners() async { - final settings = context.read(); - final taskService = context.read(); - - if (settings.useRealtimeUpdates && - ((await taskService.hasRunningTasks()) || - (await taskService.hasScheduledTasks()))) { - removeBackgroundFetch(); - - await configureBackgroundLocator(); - await initializeBackgroundLocator(context); - } else { - await configureBackgroundFetch(); - registerBackgroundFetch(); - } - } - - void _checkViewAlarms( - final Position position, - ) async { - final l10n = AppLocalizations.of(context); - final viewService = context.read(); - final userLocation = await LocationPointService.fromPosition(position); - - if (!mounted) { - return; - } - - checkViewAlarms( - l10n: l10n, - viewService: viewService, - userLocation: userLocation, - ); - } - void _initLiveLocationUpdate() { if (_positionStream != null) { return; @@ -390,33 +301,14 @@ class _LocationsOverviewScreenState extends State ); _positionStream!.listen((position) async { - final taskService = context.read(); final currentLocation = context.read(); currentLocation.updateCurrentPosition(position); - _checkViewAlarms(position); - _updateLocationToSettings(position); - setState(() { lastPosition = position; locationStatus = LocationStatus.active; }); - - final runningTasks = await taskService.getRunningTasks().toList(); - - if (runningTasks.isEmpty) { - return; - } - - final locationData = await LocationPointService.fromPosition(position); - - for (final task in runningTasks) { - await task.publisher.publishOutstandingPositions(); - await task.publisher.publishLocation( - locationData.copyWithDifferentId(), - ); - } }); } @@ -425,63 +317,10 @@ class _LocationsOverviewScreenState extends State _positionStream = null; } - Future _importUniLink(final String url) => showPlatformModalSheet( - context: context, - material: MaterialModalSheetData( - isScrollControlled: true, - isDismissible: true, - backgroundColor: Colors.transparent, - ), - builder: (context) => ImportTaskSheet(initialURL: url), - ); - - Future _initUniLinks() async { - final l10n = AppLocalizations.of(context); - - FlutterLogs.logInfo(LOG_TAG, "Uni Links", "Initiating uni links..."); - - _uniLinksStream = linkStream.listen((final String? link) { - if (link != null) { - _importUniLink(link); - } - }); - - try { - // Only fired when the app was in background - final initialLink = await getInitialLink(); - - if (initialLink != null) { - await _importUniLink(initialLink); - } - } on PlatformException catch (error) { - FlutterLogs.logError( - LOG_TAG, - "Uni Links", - "Error initializing uni links: $error", - ); - - showPlatformDialog( - context: context, - builder: (_) => PlatformAlertDialog( - title: Text(l10n.uniLinksOpenError), - content: Text(error.message ?? l10n.unknownError), - actions: [ - PlatformDialogAction( - child: Text(l10n.closeNeutralAction), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - ), - ); - } - } - void _handleViewAlarmChecker() { _viewsAlarmCheckerTimer = Timer.periodic( const Duration(minutes: 1), - (_) { + (_) { final viewService = context.read(); if (viewService.viewsWithAlarms.isEmpty) { @@ -491,105 +330,7 @@ class _LocationsOverviewScreenState extends State ); } - void _handleNotifications() { - selectedNotificationsStream.stream.listen((notification) { - FlutterLogs.logInfo( - LOG_TAG, - "Notification", - "Notification received: ${notification.payload}", - ); - - try { - final data = jsonDecode(notification.payload ?? "{}"); - final type = NotificationActionType.values[data["type"]]; - - switch (type) { - case NotificationActionType.openTaskView: - final viewService = context.read(); - - Navigator.of(context).push( - NativePageRoute( - context: context, - builder: (_) => ViewDetailsScreen( - view: viewService.getViewById(data["taskViewID"]), - ), - ), - ); - break; - case NotificationActionType.openPermissionsSettings: - openAppSettings(); - - break; - } - } catch (error) { - FlutterLogs.logError( - LOG_TAG, - "Notification", - "Error handling notification: $error", - ); - } - }); - } - - void _updateLocaleToSettings() { - final settingsService = context.read(); - - settingsService.localeName = AppLocalizations.of(context).localeName; - settingsService.save(); - } - - void _showUpdateDialogIfRequired() async { - final l10n = AppLocalizations.of(context); - final appUpdateService = context.read(); - - if (appUpdateService.shouldShowDialogue() && - !appUpdateService.hasShownDialogue && - mounted) { - await showPlatformDialog( - context: context, - barrierDismissible: false, - material: MaterialDialogData( - barrierColor: Colors.black, - ), - builder: (context) => PlatformAlertDialog( - title: Text(l10n.updateAvailable_android_title), - content: Text(l10n.updateAvailable_android_description), - actions: [ - PlatformDialogAction( - onPressed: () { - Navigator.of(context).pop(); - }, - material: (context, _) => MaterialDialogActionData( - icon: const Icon(Icons.watch_later_rounded)), - child: Text(l10n.updateAvailable_android_remindLater), - ), - PlatformDialogAction( - onPressed: () { - appUpdateService.doNotShowDialogueAgain(); - - Navigator.of(context).pop(); - }, - material: (context, _) => - MaterialDialogActionData(icon: const Icon(Icons.block)), - child: Text(l10n.updateAvailable_android_ignore), - ), - PlatformDialogAction( - onPressed: appUpdateService.openStoreForUpdate, - material: (context, _) => - MaterialDialogActionData(icon: const Icon(Icons.download)), - child: Text(l10n.updateAvailable_android_download), - ), - ], - ), - ); - - appUpdateService.setHasShownDialogue(); - } - } - - Future _animateToPosition( - final Position position, - ) async { + Future _animateToPosition(final Position position,) async { if (flutterMapController != null) { final zoom = max(15, flutterMapController!.zoom).toDouble(); @@ -713,7 +454,9 @@ class _LocationsOverviewScreenState extends State return CurrentLocationLayer( positionStream: - context.read().locationMarkerStream, + context + .read() + .locationMarkerStream, followOnLocationUpdate: FollowOnLocationUpdate.never, style: LocationMarkerStyle( marker: DefaultLocationMarker( @@ -731,21 +474,32 @@ class _LocationsOverviewScreenState extends State final locationFetchers = context.read(); final Iterable<(TaskView, LocationPointService)> circleLocations = - selectedViewID == null - ? locationFetchers.fetchers - .where((fetcher) => fetcher.sortedLocations.isNotEmpty) - .map((fetcher) => (fetcher.view, fetcher.sortedLocations.last)) - : viewService.views - .map( - (view) => mergeLocationsIfRequired( - locationFetchers - .getLocations(view) - .whereNot((location) => location == visibleLocation) - .toList(), - ), - ) - .expand((element) => element) - .map((location) => (selectedView!, location)); + selectedViewID == null + ? locationFetchers.fetchers + .where((fetcher) => fetcher.sortedLocations.isNotEmpty) + .map((fetcher) => (fetcher.view, fetcher.sortedLocations.last)) + : viewService.views + .map( + (view) => + mergeLocationsIfRequired( + locationFetchers + .getLocations(view) + .whereNot((location) => location == visibleLocation) + .toList(), + ), + ) + .expand((element) => element) + .map((location) => (selectedView!, location)); + final ownLocations = context + .watch() + .previewLocations + .map( + (location) => + LatLng( + location.latitude, + location.longitude, + ), + ); if (settings.getMapProvider() == MapProvider.apple) { return apple_maps.AppleMap( @@ -776,29 +530,30 @@ class _LocationsOverviewScreenState extends State (view) => selectedViewID == null || view.id == selectedViewID) .map( (view) => - mergeLocationsIfRequired(locationFetchers.getLocations(view)) - .map( - (location) => apple_maps.Circle( - circleId: apple_maps.CircleId(location.id), - center: apple_maps.LatLng( - location.latitude, - location.longitude, - ), - radius: location.accuracy, - fillColor: view.color.withOpacity(0.2), - strokeColor: view.color, - strokeWidth: location.accuracy < 10 ? 1 : 3), - ) - .toList(), - ) + mergeLocationsIfRequired(locationFetchers.getLocations(view)) + .map( + (location) => + apple_maps.Circle( + circleId: apple_maps.CircleId(location.id), + center: apple_maps.LatLng( + location.latitude, + location.longitude, + ), + radius: location.accuracy, + fillColor: view.color.withOpacity(0.2), + strokeColor: view.color, + strokeWidth: location.accuracy < 10 ? 1 : 3), + ) + .toList(), + ) .expand((element) => element) .toSet(), polylines: Set.from( locationFetchers.fetchers .where((fetcher) => - selectedViewID == null || fetcher.view.id == selectedViewID) + selectedViewID == null || fetcher.view.id == selectedViewID) .map( - (fetcher) { + (fetcher) { final view = fetcher.view; return apple_maps.Polyline( @@ -816,14 +571,15 @@ class _LocationsOverviewScreenState extends State }, // TODO points: mergeLocationsIfRequired( - locationFetchers.getLocations(view)) + locationFetchers.getLocations(view)) .reversed .map( - (location) => apple_maps.LatLng( + (location) => + apple_maps.LatLng( location.latitude, location.longitude, ), - ) + ) .toList(), ); }, @@ -846,18 +602,18 @@ class _LocationsOverviewScreenState extends State CircleLayer( circles: circleLocations .map((data) { - final view = data.$1; - final location = data.$2; - - return CircleMarker( - radius: location.accuracy, - useRadiusInMeter: true, - point: LatLng(location.latitude, location.longitude), - borderStrokeWidth: 1, - color: view.color.withOpacity(.1 * colorOpacityMultiplier), - borderColor: view.color.withOpacity(colorOpacityMultiplier), - ); - }) + final view = data.$1; + final location = data.$2; + + return CircleMarker( + radius: location.accuracy, + useRadiusInMeter: true, + point: LatLng(location.latitude, location.longitude), + borderStrokeWidth: 1, + color: view.color.withOpacity(.1 * colorOpacityMultiplier), + borderColor: view.color.withOpacity(colorOpacityMultiplier), + ); + }) .toList() .cast(), ), @@ -881,9 +637,9 @@ class _LocationsOverviewScreenState extends State polylines: List.from( locationFetchers.fetchers .where((fetcher) => - selectedViewID == null || fetcher.view.id == selectedViewID) + selectedViewID == null || fetcher.view.id == selectedViewID) .map( - (fetcher) { + (fetcher) { final view = fetcher.view; final locations = mergeLocationsIfRequired( locationFetchers.getLocations(view), @@ -894,16 +650,16 @@ class _LocationsOverviewScreenState extends State strokeWidth: 10, strokeJoin: StrokeJoin.round, gradientColors: locations.length <= - LOCATION_POLYLINE_OPAQUE_AMOUNT_THRESHOLD + LOCATION_POLYLINE_OPAQUE_AMOUNT_THRESHOLD ? null : List.generate( - 9, (index) => view.color.withOpacity(0.9)) + - [view.color.withOpacity(.3)], + 9, (index) => view.color.withOpacity(0.9)) + + [view.color.withOpacity(.3)], points: locations.reversed .map( (location) => - LatLng(location.latitude, location.longitude), - ) + LatLng(location.latitude, location.longitude), + ) .toList(), ); }, @@ -911,6 +667,21 @@ class _LocationsOverviewScreenState extends State ), ), _buildUserMarkerLayer(), + if (ownLocations.isNotEmpty) + PolylineLayer( + polylines: [ + Polyline( + strokeWidth: 10, + strokeJoin: StrokeJoin.round, + gradientColors: ownLocations + .mapIndexed((index, _) => + Colors.cyanAccent + .withOpacity(index / ownLocations.length)) + .toList(), + points: ownLocations.toList(), + ), + ], + ), PopupMarkerLayer( options: PopupMarkerLayerOptions( markerTapBehavior: MarkerTapBehavior.togglePopupAndHideRest(), @@ -918,7 +689,7 @@ class _LocationsOverviewScreenState extends State popupDisplayOptions: PopupDisplayOptions( builder: (context, marker) { final view = viewService.views.firstWhere( - (view) => Key(view.id) == marker.key, + (view) => Key(view.id) == marker.key, ); return ViewLocationPopup( @@ -936,10 +707,14 @@ class _LocationsOverviewScreenState extends State ), markers: viewService.views .where((view) => - (selectedViewID == null || view.id == selectedViewID) && - locationFetchers.getLocations(view).isNotEmpty) + (selectedViewID == null || view.id == selectedViewID) && + locationFetchers + .getLocations(view) + .isNotEmpty) .map((view) { - final latestLocation = locationFetchers.getLocations(view).last; + final latestLocation = locationFetchers + .getLocations(view) + .last; return Marker( key: Key(view.id), @@ -948,18 +723,19 @@ class _LocationsOverviewScreenState extends State latestLocation.longitude, ), anchorPos: AnchorPos.align(AnchorAlign.top), - builder: (context) => Icon( - Icons.location_on, - size: 40, - color: view.color, - shadows: const [ - Shadow( - blurRadius: 10, - color: Colors.black, - offset: Offset(0, 0), + builder: (context) => + Icon( + Icons.location_on, + size: 40, + color: view.color, + shadows: const [ + Shadow( + blurRadius: 10, + color: Colors.black, + offset: Offset(0, 0), + ), + ], ), - ], - ), ); }).toList(), ), @@ -974,10 +750,11 @@ class _LocationsOverviewScreenState extends State return Stack( children: locationFetchers.fetchers .where((fetcher) => - (selectedViewID == null || fetcher.view.id == selectedViewID) && - fetcher.sortedLocations.isNotEmpty) + (selectedViewID == null || fetcher.view.id == selectedViewID) && + fetcher.sortedLocations.isNotEmpty) .map( - (fetcher) => OutOfBoundMarker( + (fetcher) => + OutOfBoundMarker( lastViewLocation: fetcher.sortedLocations.last, onTap: () { showViewLocations(fetcher.view); @@ -987,13 +764,12 @@ class _LocationsOverviewScreenState extends State appleMapController: appleMapController, flutterMapController: flutterMapController, ), - ) + ) .toList(), ); } - void showViewLocations( - final TaskView view, { + void showViewLocations(final TaskView view, { final bool jumpToLatestLocation = true, }) async { final locationFetchers = context.read(); @@ -1035,8 +811,7 @@ class _LocationsOverviewScreenState extends State } } - Widget buildViewTile( - final TaskView? view, { + Widget buildViewTile(final TaskView? view, { final MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start, }) { final l10n = AppLocalizations.of(context); @@ -1091,14 +866,15 @@ class _LocationsOverviewScreenState extends State showCupertinoModalPopup( context: context, barrierDismissible: true, - builder: (cupertino) => CupertinoActionSheet( - cancelButton: CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(context); - }, - child: Text(l10n.cancelLabel), - ), - actions: [ + builder: (cupertino) => + CupertinoActionSheet( + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + }, + child: Text(l10n.cancelLabel), + ), + actions: [ CupertinoActionSheetAction( child: buildViewTile( null, @@ -1113,32 +889,34 @@ class _LocationsOverviewScreenState extends State }, ) ] + - viewService.views - .map( - (view) => CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(context); - showViewLocations(view); - }, - child: buildViewTile( - view, - mainAxisAlignment: MainAxisAlignment.center, - ), - ), + viewService.views + .map( + (view) => + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + showViewLocations(view); + }, + child: buildViewTile( + view, + mainAxisAlignment: MainAxisAlignment + .center, + ), + ), ) - .toList(), - ), + .toList(), + ), ); }, child: selectedViewID == null ? Icon( - Icons.location_on_rounded, - color: settings.getPrimaryColor(context), - ) + Icons.location_on_rounded, + color: settings.getPrimaryColor(context), + ) : Icon( - Icons.circle_rounded, - color: selectedView!.color, - ), + Icons.circle_rounded, + color: selectedView!.color, + ), ), ), ), @@ -1159,88 +937,93 @@ class _LocationsOverviewScreenState extends State vertical: SMALL_SPACE, ), child: PlatformWidget( - material: (context, _) => DropdownButton( - isDense: true, - value: selectedViewID, - onChanged: (selection) { - if (selection == null) { - setState(() { - showFAB = true; - selectedViewID = null; - visibleLocation = null; - }); - return; - } - - final view = viewService.views.firstWhere( - (view) => view.id == selection, - ); - - showViewLocations(view); - }, - underline: Container(), - alignment: Alignment.center, - isExpanded: true, - items: [ - DropdownMenuItem( - value: null, - child: buildViewTile(null), - ), - for (final view in viewService.views) ...[ - DropdownMenuItem( - value: view.id, - child: buildViewTile(view), - ), - ], - ], - ), - cupertino: (context, _) => CupertinoButton( - onPressed: () { - showCupertinoModalPopup( - context: context, - barrierDismissible: true, - builder: (cupertino) => CupertinoActionSheet( - cancelButton: CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(context); - }, - child: Text(l10n.cancelLabel), + material: (context, _) => + DropdownButton( + isDense: true, + value: selectedViewID, + onChanged: (selection) { + if (selection == null) { + setState(() { + showFAB = true; + selectedViewID = null; + visibleLocation = null; + }); + return; + } + + final view = viewService.views.firstWhere( + (view) => view.id == selection, + ); + + showViewLocations(view); + }, + underline: Container(), + alignment: Alignment.center, + isExpanded: true, + items: [ + DropdownMenuItem( + value: null, + child: buildViewTile(null), ), - actions: [ - CupertinoActionSheetAction( - child: buildViewTile( - null, - mainAxisAlignment: MainAxisAlignment.center, + for (final view in viewService.views) ...[ + DropdownMenuItem( + value: view.id, + child: buildViewTile(view), + ), + ], + ], + ), + cupertino: (context, _) => + CupertinoButton( + onPressed: () { + showCupertinoModalPopup( + context: context, + barrierDismissible: true, + builder: (cupertino) => + CupertinoActionSheet( + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + }, + child: Text(l10n.cancelLabel), ), - onPressed: () { - Navigator.pop(context); - setState(() { - selectedViewID = null; - visibleLocation = null; - }); - }, - ) - ] + - viewService.views - .map( - (view) => CupertinoActionSheetAction( + actions: [ + CupertinoActionSheetAction( + child: buildViewTile( + null, + mainAxisAlignment: MainAxisAlignment + .center, + ), onPressed: () { Navigator.pop(context); - showViewLocations(view); + setState(() { + selectedViewID = null; + visibleLocation = null; + }); }, - child: buildViewTile( - view, - mainAxisAlignment: - MainAxisAlignment.center, - ), - ), - ) - .toList(), - ), - ); - }, - child: buildViewTile(selectedView), - ), + ) + ] + + viewService.views + .map( + (view) => + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + showViewLocations(view); + }, + child: buildViewTile( + view, + mainAxisAlignment: + MainAxisAlignment.center, + ), + ), + ) + .toList(), + ), + ); + }, + child: buildViewTile(selectedView), + ), ), ), ), @@ -1295,7 +1078,7 @@ class _LocationsOverviewScreenState extends State final settings = context.read(); final link = - await (task as Task).publisher.generateLink(settings.getServerHost()); + await (task as Task).publisher.generateLink(settings.getServerHost()); // Copy to clipboard await Clipboard.setData(ClipboardData(text: link)); @@ -1325,7 +1108,7 @@ class _LocationsOverviewScreenState extends State AnimatedScale( scale: showDetailedLocations ? 1 : 0, duration: - showDetailedLocations ? 1200.milliseconds : 100.milliseconds, + showDetailedLocations ? 1200.milliseconds : 100.milliseconds, curve: showDetailedLocations ? Curves.elasticOut : Curves.easeIn, child: Tooltip( message: disableShowDetailedLocations @@ -1348,7 +1131,7 @@ class _LocationsOverviewScreenState extends State onPressed: () { setState(() { disableShowDetailedLocations = - !disableShowDetailedLocations; + !disableShowDetailedLocations; }); }, ), @@ -1445,7 +1228,7 @@ class _LocationsOverviewScreenState extends State onPressed: importLocation, icon: const Icon(Icons.download_rounded), label: - Text(l10n.sharesOverviewScreen_importTask_action_import), + Text(l10n.sharesOverviewScreen_importTask_action_import), backgroundColor: background, foregroundColor: foreground, ), @@ -1527,58 +1310,62 @@ class _LocationsOverviewScreenState extends State showCupertinoModalPopup( context: context, barrierDismissible: true, - builder: (cupertino) => CupertinoActionSheet( - cancelButton: CupertinoActionSheetAction( - onPressed: () => Navigator.pop(context), - child: Text(l10n.cancelLabel), - ), - actions: [ - CupertinoActionSheetAction( - onPressed: withPopNavigation(createNewQuickLocationShare)( - context), - child: CupertinoListTile( - leading: const Icon(Icons.share_location_rounded), - title: Text(l10n.shareLocation_title), + builder: (cupertino) => + CupertinoActionSheet( + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancelLabel), ), - ), - CupertinoActionSheetAction( - onPressed: withPopNavigation(importLocation)(context), - child: CupertinoListTile( - leading: + actions: [ + CupertinoActionSheetAction( + onPressed: withPopNavigation( + createNewQuickLocationShare)( + context), + child: CupertinoListTile( + leading: const Icon(Icons.share_location_rounded), + title: Text(l10n.shareLocation_title), + ), + ), + CupertinoActionSheetAction( + onPressed: withPopNavigation(importLocation)(context), + child: CupertinoListTile( + leading: const Icon(CupertinoIcons.square_arrow_down_fill), - title: Text( - l10n.sharesOverviewScreen_importTask_action_import), - ), - ), - CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(context); + title: Text( + l10n + .sharesOverviewScreen_importTask_action_import), + ), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); - Navigator.push( - context, - MaterialWithModalsPageRoute( - builder: (context) => const SharesOverviewScreen(), + Navigator.push( + context, + MaterialWithModalsPageRoute( + builder: ( + context) => const SharesOverviewScreen(), + ), + ); + }, + child: CupertinoListTile( + leading: const Icon(CupertinoIcons.list_bullet), + title: Text(l10n.sharesOverviewScreen_title), ), - ); - }, - child: CupertinoListTile( - leading: const Icon(CupertinoIcons.list_bullet), - title: Text(l10n.sharesOverviewScreen_title), - ), - ), - CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(context); + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); - showSettings(context); - }, - child: CupertinoListTile( - leading: Icon(context.platformIcons.settings), - title: Text(l10n.settingsScreen_title), - ), + showSettings(context); + }, + child: CupertinoListTile( + leading: Icon(context.platformIcons.settings), + title: Text(l10n.settingsScreen_title), + ), + ), + ], ), - ], - ), ); }, ), diff --git a/lib/screens/ShortcutScreen.dart b/lib/screens/ShortcutScreen.dart index 3fb8ee3a..5bf8fe34 100644 --- a/lib/screens/ShortcutScreen.dart +++ b/lib/screens/ShortcutScreen.dart @@ -2,8 +2,8 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/app_wrappers/ManageQuickActions.dart'; import 'package:locus/constants/spacing.dart'; -import 'package:locus/init_quick_actions.dart'; import 'package:locus/services/task_service/index.dart'; import 'package:locus/services/timers_service.dart'; import 'package:locus/utils/location/index.dart'; diff --git a/lib/screens/WelcomeScreen.dart b/lib/screens/WelcomeScreen.dart index 2c575851..ab5c2cc2 100644 --- a/lib/screens/WelcomeScreen.dart +++ b/lib/screens/WelcomeScreen.dart @@ -6,7 +6,6 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/svg.dart'; import 'package:locus/constants/spacing.dart'; -import 'package:locus/init_quick_actions.dart'; import 'package:locus/screens/LocationsOverviewScreen.dart'; import 'package:locus/screens/welcome_screen_widgets/SimpleContinuePage.dart'; import 'package:locus/services/settings_service/index.dart'; @@ -30,14 +29,6 @@ class WelcomeScreen extends StatefulWidget { class _WelcomeScreenState extends State { final PageController _controller = PageController(); - @override - void initState() { - super.initState(); - - // Reset - actions.clearShortcutItems(); - } - @override void dispose() { _controller.dispose(); diff --git a/lib/services/current_location_service.dart b/lib/services/current_location_service.dart index 065db0b5..8e11d71b 100644 --- a/lib/services/current_location_service.dart +++ b/lib/services/current_location_service.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter_map_location_marker/flutter_map_location_marker.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:locus/screens/LocationsOverviewScreen.dart'; class CurrentLocationService extends ChangeNotifier { final StreamController _positionStreamController = @@ -11,6 +12,7 @@ class CurrentLocationService extends ChangeNotifier { final StreamController _locationMarkerStreamController = StreamController.broadcast(); Position? currentPosition; + LocationStatus locationStatus = LocationStatus.stale; Stream get stream => _positionStreamController.stream; diff --git a/lib/services/location_history_service/constants.dart b/lib/services/location_history_service/constants.dart new file mode 100644 index 00000000..c94cbc4f --- /dev/null +++ b/lib/services/location_history_service/constants.dart @@ -0,0 +1,4 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +const storage = FlutterSecureStorage(); +const KEY = "_locus_own_location_history_v1"; diff --git a/lib/services/location_history_service/index.dart b/lib/services/location_history_service/index.dart new file mode 100644 index 00000000..33eb5636 --- /dev/null +++ b/lib/services/location_history_service/index.dart @@ -0,0 +1 @@ +export "./location_history.dart"; diff --git a/lib/services/location_history_service/location_history.dart b/lib/services/location_history_service/location_history.dart new file mode 100644 index 00000000..f5f5ac9d --- /dev/null +++ b/lib/services/location_history_service/location_history.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:geolocator/geolocator.dart'; + +import "./constants.dart"; +import '../location_point_service.dart'; + +class LocationHistory extends ChangeNotifier { + late final List locations; + + LocationHistory(final List? locations,) + : locations = locations ?? []; + + // Locations used for the user preview. Only shows the locations in the last + // hour + List get previewLocations { + final minDate = DateTime.now().subtract(const Duration(hours: 1)); + + return locations + .where((location) => + location.timestamp != null && location.timestamp!.isAfter(minDate)) + .sorted((a, b) => a.timestamp!.compareTo(b.timestamp!)) + .toList(); + } + + factory LocationHistory.fromJSON(final Map data) => + LocationHistory( + data["locations"] != null + ? List.from( + data["locations"].map( + (location) => + Position.fromMap(location as Map), + ), + ) + : null, + ); + + static Future restore() async { + final data = await storage.read(key: KEY); + + if (data == null) { + return LocationHistory(null); + } + + return LocationHistory.fromJSON(jsonDecode(data) as Map); + } + + // To avoid too many crumbled locations, we only save locations that are at + // least one minute apart + bool _canAdd(final Position position) { + return position.timestamp != null; + } + + void add(final Position position) { + final lastLocation = locations.lastOrNull; + + if (lastLocation != null && + lastLocation.timestamp!.difference(position.timestamp!).abs() <= + const Duration(minutes: 1)) { + // Replace oldest one with new one + locations.removeLast(); + } + + locations.add(position); + + final strippedLocations = locations.take(60).toList(); + + locations.clear(); + locations.addAll(strippedLocations); + + notifyListeners(); + } + + void clear() { + locations.clear(); + notifyListeners(); + } + + Map toJSON() => + { + "locations": locations.map((location) => location.toJson()).toList(), + }; + + Future save() async { + await storage.write( + key: KEY, + value: jsonEncode(toJSON()), + ); + } +} diff --git a/lib/services/manager_service/background_locator.dart b/lib/services/manager_service/background_locator.dart index 5401924a..6a885c79 100644 --- a/lib/services/manager_service/background_locator.dart +++ b/lib/services/manager_service/background_locator.dart @@ -68,7 +68,7 @@ Future configureBackgroundLocator() { return BackgroundLocator.initialize(); } -Future initializeBackgroundLocator(final BuildContext context,) { +Future registerBackgroundLocator(final BuildContext context) { final l10n = AppLocalizations.of(context); FlutterLogs.logInfo( @@ -83,7 +83,7 @@ Future initializeBackgroundLocator(final BuildContext context,) { androidSettings: AndroidSettings( accuracy: LocationAccuracy.HIGH, distanceFilter: - BACKGROUND_LOCATION_UPDATES_MINIMUM_DISTANCE_FILTER.toDouble(), + BACKGROUND_LOCATION_UPDATES_MINIMUM_DISTANCE_FILTER.toDouble(), client: isGMSFlavor ? LocationClient.google : LocationClient.android, androidNotificationSettings: AndroidNotificationSettings( notificationTitle: l10n.backgroundLocator_title, @@ -95,7 +95,7 @@ Future initializeBackgroundLocator(final BuildContext context,) { ), iosSettings: IOSSettings( distanceFilter: - BACKGROUND_LOCATION_UPDATES_MINIMUM_DISTANCE_FILTER.toDouble(), + BACKGROUND_LOCATION_UPDATES_MINIMUM_DISTANCE_FILTER.toDouble(), accuracy: LocationAccuracy.HIGH, showsBackgroundLocationIndicator: true, stopWithTerminate: false, diff --git a/lib/services/manager_service/task.dart b/lib/services/manager_service/task.dart index aa5445bb..76279206 100644 --- a/lib/services/manager_service/task.dart +++ b/lib/services/manager_service/task.dart @@ -1,11 +1,14 @@ import 'dart:convert'; +import 'package:airplane_mode_checker/airplane_mode_checker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_logs/flutter_logs.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:locus/constants/notifications.dart'; import 'package:locus/constants/values.dart'; +import 'package:locus/services/location_history_service/index.dart'; import 'package:locus/services/location_point_service.dart'; import 'package:locus/services/manager_service/helpers.dart'; import 'package:locus/services/settings_service/index.dart'; @@ -40,10 +43,59 @@ void _showPermissionMissingNotification({ ); } +void _updateLocation(final Position position) async { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Updating Location History; Restoring...", + ); + + final locationHistory = await LocationHistory.restore(); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Updating Location History; Adding position.", + ); + + locationHistory.add(position); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Updating Location History; Saving...", + ); + + await locationHistory.save(); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Updating Location History; Done!", + ); +} + Future runBackgroundTask({ final LocationPointService? locationData, final bool force = false, }) async { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Checking Airplane mode", + ); + + final status = await AirplaneModeChecker.checkAirplaneMode(); + + if (status == AirplaneModeStatus.on) { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "----> Airplane mode is on. Skipping headless task.", + ); + return; + } + FlutterLogs.logInfo( LOG_TAG, "Headless Task", @@ -75,6 +127,18 @@ Future runBackgroundTask({ return; } + final location = locationData ?? await getLocationData(); + + try { + _updateLocation(location.asPosition()); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Headless Task", + "Error while updating location history: $error", + ); + } + if (!force) { FlutterLogs.logInfo( LOG_TAG, @@ -89,7 +153,7 @@ Future runBackgroundTask({ DateTime.now().difference(settings.lastHeadlessRun!).abs() > BATTERY_SAVER_ENABLED_MINIMUM_TIME_BETWEEN_HEADLESS_RUNS; - if (shouldRunBasedOnBatterySaver && shouldRunBasedOnLastRun) { + if (!shouldRunBasedOnBatterySaver && !shouldRunBasedOnLastRun) { // We don't want to run the headless task too often when the battery saver is enabled. FlutterLogs.logInfo( LOG_TAG, @@ -108,8 +172,6 @@ Future runBackgroundTask({ "Executing headless task now.", ); - final location = locationData ?? await getLocationData(); - FlutterLogs.logInfo( LOG_TAG, "Headless Task", diff --git a/lib/services/settings_service/SettingsMapLocation.dart b/lib/services/settings_service/SettingsMapLocation.dart index 528b262c..f6641b74 100644 --- a/lib/services/settings_service/SettingsMapLocation.dart +++ b/lib/services/settings_service/SettingsMapLocation.dart @@ -1,3 +1,4 @@ +import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; class SettingsLastMapLocation { @@ -26,4 +27,18 @@ class SettingsLastMapLocation { }; LatLng toLatLng() => LatLng(latitude, longitude); + + Position asPosition() => + Position( + latitude: latitude, + longitude: longitude, + accuracy: accuracy, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + timestamp: DateTime.now(), + altitude: 0, + heading: 0, + speed: 0, + speedAccuracy: 0, + ); } diff --git a/pubspec.lock b/pubspec.lock index d126e0fa..03837098 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.1" + airplane_mode_checker: + dependency: "direct main" + description: + name: airplane_mode_checker + sha256: "16d7de1125056d6f7a9c5c1dc0056afe78c36a9e8fc39768ef9f9c21b07c0d5e" + url: "https://pub.dev" + source: hosted + version: "2.0.0" analyzer: dependency: transitive description: @@ -645,6 +653,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: transitive + description: + name: fluttertoast + sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c" + url: "https://pub.dev" + source: hosted + version: "8.2.2" frontend_server_client: dependency: transitive description: @@ -1699,5 +1715,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.0.0 <4.0.0" + dart: ">=3.1.0-185.0.dev <4.0.0" flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index af7f3a34..7bff7670 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -102,6 +102,7 @@ dependencies: collection: ^1.17.1 background_locator_2: ^2.0.6 queue: ^3.1.0+2 + airplane_mode_checker: ^2.0.0 # Uncomment this for publishing FLOSS variant # Taken from https://github.com/Zverik/every_door/blob/aaf8d2fdeac483041bcac2c7c79ef760b99dff2b/pubspec.yaml#L55