diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2457a29f..22e18d8d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ + + + @@ -79,6 +82,22 @@ android:scheme="https" /> + + + + + + + + + () { + if (!registry.hasPlugin("BackgroundLocatorPlugin")) { + GeneratedPluginRegistrant.register(with: registry) + } +} @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { @@ -8,6 +15,7 @@ import Flutter didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + BackgroundLocatorPlugin.setPluginRegistrantCallback(registerPlugins) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/lib/App.dart b/lib/App.dart index fdc0ffab..e540f0df 100644 --- a/lib/App.dart +++ b/lib/App.dart @@ -6,7 +6,7 @@ import 'package:locus/constants/colors.dart'; import 'package:locus/screens/BiometricsRequiredStartupScreen.dart'; import 'package:locus/screens/LocationsOverviewScreen.dart'; import 'package:locus/screens/WelcomeScreen.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:locus/utils/PageRoute.dart'; import 'package:locus/utils/color.dart'; import 'package:locus/widgets/DismissKeyboard.dart'; diff --git a/lib/api/get-locations.dart b/lib/api/get-locations.dart deleted file mode 100644 index 1ec9b8a9..00000000 --- a/lib/api/get-locations.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:async'; - -import 'package:cryptography/cryptography.dart'; -import 'package:flutter/animation.dart'; -import 'package:flutter_logs/flutter_logs.dart'; -import 'package:locus/constants/values.dart'; -import 'package:locus/services/location_point_service.dart'; -import 'package:nostr/nostr.dart'; - -import 'nostr-fetch.dart'; - -VoidCallback getLocations({ - required final String nostrPublicKey, - required final SecretKey encryptionPassword, - required final List relays, - required void Function(LocationPointService) onLocationFetched, - required void Function() onEnd, - final VoidCallback? onEmptyEnd, - int? limit, - DateTime? from, - DateTime? until, -}) { - final request = Request(generate64RandomHexChars(), [ - Filter( - kinds: [1000], - authors: [nostrPublicKey], - limit: limit, - until: - until == null ? null : (until.millisecondsSinceEpoch / 1000).floor(), - since: from == null ? null : (from.millisecondsSinceEpoch / 1000).floor(), - ), - ]); - - final nostrFetch = NostrFetch( - relays: relays, - request: request, - ); - - return nostrFetch.fetchEvents( - onEvent: (message, _) async { - FlutterLogs.logInfo( - LOG_TAG, - "GetLocations", - "New message. Decrypting...", - ); - - try { - final location = await LocationPointService.fromEncrypted( - message.message.content, - encryptionPassword, - ); - - FlutterLogs.logInfo( - LOG_TAG, - "GetLocations", - "New message. Decrypting... Done!", - ); - - onLocationFetched(location); - } catch (error) { - FlutterLogs.logError( - LOG_TAG, - "GetLocations", - "Error decrypting message: $error", - ); - - return; - } - }, - onEnd: onEnd, - onEmptyEnd: onEmptyEnd, - ); -} - -Future> getLocationsAsFuture({ - required final String nostrPublicKey, - required final SecretKey encryptionPassword, - required final List relays, - int? limit, - DateTime? from, - DateTime? until, -}) async { - final completer = Completer>(); - - final List locations = []; - - getLocations( - nostrPublicKey: nostrPublicKey, - encryptionPassword: encryptionPassword, - relays: relays, - limit: limit, - from: from, - until: until, - onLocationFetched: (final LocationPointService location) { - locations.add(location); - }, - onEnd: () { - completer.complete(locations); - }, - ); - - return completer.future; -} diff --git a/lib/api/get-relays-meta.dart b/lib/api/get-relays-meta.dart new file mode 100644 index 00000000..c1881157 --- /dev/null +++ b/lib/api/get-relays-meta.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:locus/utils/access-deeply-nested-key.dart'; +import 'package:locus/utils/nostr_fetcher/BasicNostrFetchSocket.dart'; +import 'package:locus/utils/nostr_fetcher/NostrSocket.dart'; +import 'package:nostr/nostr.dart'; + +const MIN_LENGTH = 5000; + +class RelaysMetaFetcher extends BasicNostrFetchSocket { + List meta = []; + + RelaysMetaFetcher({ + required super.relay, + super.timeout, + }); + + @override + void onEndOfStream() { + closeConnection(); + } + + @override + void onNostrEvent(final Message message) { + // Relay URL, canWrite and canRead are in message.tags + // Latencies are saved in content, separated per region + // with the following schema: + // [ + // [], + // [], + // [], + // ] + final event = message.message as Event; + + final relayMeta = RelayMeta.fromFetchedContent( + canWrite: event.tags[1][1] == "true", + canRead: event.tags[2][1] == "true", + relay: event.tags[0][1], + content: jsonDecode(event.content), + worldRegion: "eu-west", + ); + + meta.add(relayMeta); + } + + @override + void onError(error) { + closeConnection(); + } +} + +class RelayMeta { + final String relay; + final bool canWrite; + final bool canRead; + final String contactInfo; + final String description; + final String name; + + final List connectionLatencies; + final List readLatencies; + final List writeLatencies; + + final int maxMessageLength; + final int maxContentLength; + + final int minPowDifficulty; + final bool requiresPayment; + + const RelayMeta({ + required this.relay, + required this.canWrite, + required this.canRead, + required this.contactInfo, + required this.description, + required this.name, + required this.connectionLatencies, + required this.readLatencies, + required this.writeLatencies, + required this.maxMessageLength, + required this.maxContentLength, + required this.minPowDifficulty, + required this.requiresPayment, + }); + + factory RelayMeta.fromFetchedContent({ + required final Map content, + required final String relay, + required final bool canRead, + required final bool canWrite, + required final String worldRegion, + }) => + RelayMeta( + relay: relay, + canRead: canRead, + canWrite: canWrite, + name: adnk(content, "info.name") ?? relay, + contactInfo: adnk(content, "info.contact") ?? "", + description: adnk(content, "info.description") ?? "", + connectionLatencies: List.from( + adnk(content, "latency.$worldRegion.0") ?? []) + .where((value) => value != null) + .toList() + .cast(), + readLatencies: + List.from(adnk(content, "latency.$worldRegion.1") ?? []) + .where((value) => value != null) + .toList() + .cast(), + writeLatencies: + List.from(adnk(content, "latency.$worldRegion.2") ?? []) + .where((value) => value != null) + .toList() + .cast(), + maxContentLength: + adnk(content, "info.limitations.max_content_length") ?? + MIN_LENGTH, + maxMessageLength: + adnk(content, "info.limitations.max_message_length") ?? + MIN_LENGTH, + requiresPayment: + adnk(content, "info.limitations.payment_required") ?? + false, + minPowDifficulty: + adnk(content, "info.limitations.min_pow_difficulty") ?? + 0); + + bool get isSuitable => + canWrite && + canRead && + !requiresPayment && + minPowDifficulty == 0 && + maxContentLength >= MIN_LENGTH; + + // Calculate average latency, we use the average as we want extreme highs + // to be taken into account. + double get score { + if (connectionLatencies.isEmpty || + readLatencies.isEmpty || + writeLatencies.isEmpty) { + // If there is no data available, we don't know if the relay is fully intact + return double.infinity; + } + + // Each latency has it's own factor to give each of them a different weight + // Lower latency = better - Because of this + // a factor closer to 0 resembles a HIGHER weight + // We prioritize read latency as we want to be able to provide a fast app + // Lower score = better + return (connectionLatencies.average * 0.9 + + readLatencies.average * 0.5 + + writeLatencies.average) + + (maxContentLength - MIN_LENGTH) * 0.0001; + } +} + +// Values taken from https://github.com/dskvr/nostr-watch/blob/develop/src/components/relays/jobs/LoadSeed.vue#L91 +final REQUEST_DATA = NostrSocket.createNostrRequestData( + kinds: [30304], + limit: 1000, + from: DateTime.now().subtract(2.hours), + authors: ["b3b0d247f66bf40c4c9f4ce721abfe1fd3b7529fbc1ea5e64d5f0f8df3a4b6e6"], +); + +Future>> fetchRelaysMeta() async { + final fetcher = RelaysMetaFetcher( + relay: "wss://history.nostr.watch", + ); + await fetcher.connect(); + fetcher.addData( + Request( + generate64RandomHexChars(), + [ + REQUEST_DATA, + ], + ).serialize(), + ); + await fetcher.onComplete; + + return { + "meta": fetcher.meta, + }; +} diff --git a/lib/api/nostr-events.dart b/lib/api/nostr-events.dart index 5ae1b1e1..dfa88cf4 100644 --- a/lib/api/nostr-events.dart +++ b/lib/api/nostr-events.dart @@ -2,10 +2,9 @@ import 'dart:io'; import 'package:flutter_logs/flutter_logs.dart'; import 'package:locus/constants/values.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:nostr/nostr.dart'; -import '../services/task_service.dart'; - class NostrEventsManager { final List relays; final String _privateKey; @@ -15,8 +14,7 @@ class NostrEventsManager { required this.relays, required String privateKey, WebSocket? socket, - }) - : _privateKey = privateKey, + }) : _privateKey = privateKey, _socket = socket; static NostrEventsManager fromTask(final Task task) { @@ -48,10 +46,7 @@ class NostrEventsManager { ); FlutterLogs.logInfo( - LOG_TAG, - "NostrEventsManager", - "publishMessage: Publishing new event." - ); + LOG_TAG, "NostrEventsManager", "publishMessage: Publishing new event."); var failedRelaysNumber = 0; diff --git a/lib/api/nostr-fetch.dart b/lib/api/nostr-fetch.dart index dd368cb8..c9d71d8e 100644 --- a/lib/api/nostr-fetch.dart +++ b/lib/api/nostr-fetch.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:ui'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_logs/flutter_logs.dart'; import 'package:nostr/nostr.dart'; @@ -15,6 +16,7 @@ class NostrFetch { required this.request, }); + // TODO: Refactor all of nostr fetching logic Future _connectToRelay({ required final String relay, required final Future Function(Message message, String relay) onEvent, @@ -98,6 +100,20 @@ class NostrFetch { } }); + socket.timeout( + 3.seconds, + onTimeout: (event) { + FlutterLogs.logError( + LOG_TAG, + "Nostr Socket $relay", + "Socket timed out.", + ); + + socket.close(); + onEmptyEnd?.call(); + }, + ); + return socket; } @@ -105,6 +121,7 @@ class NostrFetch { required final Future Function(Message message, String relay) onEvent, required final void Function() onEnd, final void Function()? onEmptyEnd, + final VoidCallback? onError, }) { final List sockets = []; @@ -136,6 +153,8 @@ class NostrFetch { "Nostr Socket", "Error for socket: $error", ); + + onError?.call(); }); } diff --git a/lib/constants/notifications.dart b/lib/constants/notifications.dart index f79417d7..8f2a9940 100644 --- a/lib/constants/notifications.dart +++ b/lib/constants/notifications.dart @@ -1,7 +1,9 @@ enum AndroidChannelIDs { locationAlarms, + appIssues, } enum NotificationActionType { openTaskView, + openPermissionsSettings, } diff --git a/lib/constants/values.dart b/lib/constants/values.dart index eaa19f53..8d9f838a 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 = 25; +const BACKGROUND_LOCATION_UPDATES_MINIMUM_DISTANCE_FILTER = 50; const LOCATION_FETCH_TIME_LIMIT = Duration(minutes: 5); const LOCATION_INTERVAL = Duration(minutes: 1); @@ -13,7 +13,7 @@ const LOCATION_INTERVAL = Duration(minutes: 1); const TRANSFER_DATA_USERNAME = "locus_transfer"; final TRANSFER_SUCCESS_MESSAGE = Uint8List.fromList([1, 2, 3, 4]); -const CURRENT_APP_VERSION = "0.14.3"; +const CURRENT_APP_VERSION = "0.15.0"; const LOG_TAG = "LocusLog"; @@ -31,3 +31,5 @@ const LOCATION_MERGE_DISTANCE_THRESHOLD = 75.0; const LOCATION_FETCH_TIMEOUT_DURATION = Duration(minutes: 1); const FALLBACK_LOCATION_ZOOM_LEVEL = 5.0; const INITIAL_LOCATION_FETCHED_ZOOM_LEVEL = 14.0; + +const LOCATION_PUBLISH_MAX_TRIES = 3; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e76eba52..45c99ed1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -110,6 +110,8 @@ } }, "relaySelectSheet_selectRandomRelays": "Select {count} random Nostr Relays", + "relaySelectSheet_loadingRelaysMeta": "Loading Nostr Relays information...", + "relaySelectSheet_hint": "Relays are sorted from best to worst in ascending order. The best relays are at the top.", "taskAction_start": "Start Task", "taskAction_started_title": "Task started", "taskAction_started_description": "Task started at {date}", @@ -167,6 +169,7 @@ "taskAction_generateLink_process_publishing": "Publishing encrypted data...", "taskAction_generateLink_process_creatingURI": "Creating link...", "taskAction_generateLink_shareTextSubject": "Here's the link to see my location", + "taskAction_showDetails": "Show Details", "tasks_action_stopAll": "Stop tasks", "tasks_action_startAll": "Start tasks", "tasks_examples_weekend": "Weekend Getaway", @@ -253,10 +256,10 @@ "sharesOverviewScreen_importTask_importLabel": "Import", "locationsOverview_viewSelection_all": "Show all locations", "locationsOverview_activeShares_amount": "{count, plural, =0{No active shares} =1{One share active} other{{count} shares active}}", - "locationsOverview_mapAction_goToCurrentPosition": "Go to current position", - "locationsOverview_mapAction_alignNorth": "Align North", "locationsOverview_mapAction_detailedLocations_show": "Show detailed locations", "locationsOverview_mapAction_detailedLocations_hide": "Merge nearby locations", + "mapAction_goToCurrentPosition": "Go to current position", + "mapAction_alignNorth": "Align North", "createTask_title": "Define a name for your Share", "createTask_fields_name_label": "Name", "createTask_sameTaskNameAlreadyExists": "A Share with this name already exists. You can create the Share, but you will have two Shares with the same name.", @@ -439,6 +442,11 @@ "settingsScreen_settings_mapProvider_label": "Map Provider", "settingsScreen_settings_mapProvider_apple": "Apple Maps", "settingsScreen_settings_mapProvider_openStreetMap": "OpenStreetMap", + "settingsScreen_settings_useRealtimeUpdates_label": "Update in real-time", + "settingsScreen_settings_useRealtimeUpdates_description": "If enabled, Locus will always update your location in real-time. This means that your location will be updated even if you don't have the app open. This will use more battery. However, as a location sharing app, this is strongly recommended. Setting this off, Locus will only occasionally update your location in the background. This setting is only recommended for people who regularly share their location, experience battery drain and only need to know occasional location updates.", + "settingsScreen_settings_useRealtimeUpdates_dialog_title": "Are you sure you want to disable real-time updates?", + "settingsScreen_settings_useRealtimeUpdates_dialog_message": "It is strongly recommended to keep this setting enabled! Your location will only be occasionally updated if you disable real-time updates!", + "settingsScreen_settings_useRealtimeUpdates_dialog_confirm": "Disable", "settingsScreen_settings_showHints_label": "Show Hints", "settingsScreen_settings_showHints_description": "Show occasional hints on how to get the most out of Locus", "settingsScreen_settings_relays_label": "Default Relays", @@ -550,6 +558,9 @@ "@backgroundLocationFetch_text": { "description": "Keep this very short" }, + "backgroundLocator_title": "Updating location in real-time", + "backgroundLocator_text": "Locus is updating your location in real-time.", + "backgroundLocator_channelName": "Location Updater", "logs_createdAt": "{date}", "@logs_createdAt": { "placeholders": { @@ -621,8 +632,6 @@ "logs_title": "Locus automatically creates logs", "logs_description": "Logs are kept for 7 days and are automatically deleted afterwards.", "locationPointsScreen_title": "Location Points", - "bunny_unavailable": "Sorry, but it seems as if your friend is as lost as this song.", - "bunny_unavailable_action": "What's this song?", "locationAlarm_radiusBasedRegion_notificationTitle_whenEnter": "{name} entered zone {zone}", "@locationAlarm_radiusBasedRegion_notificationTitle_whenEnter": { "description": "Notification title when entering a zone; Try to keep this short, will be truncated to 80 characters", @@ -659,12 +668,38 @@ } } }, + "locationAlarm_proximityLocation_notificationTitle_whenEnter": "{name} is within {proximity}m", + "@locationAlarm_proximityLocation_notificationTitle_whenEnter": { + "description": "Notification title when entering a proximity location; Try to keep this short, will be truncated to 80 characters", + "placeholders": { + "name": { + "type": "String" + }, + "proximity": { + "type": "int" + } + } + }, + "locationAlarm_proximityLocation_notificationTitle_whenLeave": "{name} is outside of {proximity}m", + "@locationAlarm_proximityLocation_notificationTitle_whenLeave": { + "description": "Notification title when leaving a proximity location; Try to keep this short, will be truncated to 80 characters", + "placeholders": { + "name": { + "type": "String" + }, + "proximity": { + "type": "int" + } + } + }, "locationAlarm_notification_description": "Tap for more information", "androidNotificationChannel_locationAlarms_name": "Location Alarms", "androidNotificationChannel_locationAlarms_description": "Receive notifications for location alarms", + "androidNotificationChannel_appIssues_name": "Important Issues", + "androidNotificationChannel_appIssues_description": "Receive notifications for important issues about Locus (when a task is not updating, location can't be updated, etc.)", "location_manageAlarms_title": "Manage Alarms", "location_manageAlarms_empty_title": "No Alarms", - "location_manageAlarms_empty_description": "Add alarms to be notified when certain conditions are met (e.g. when this person enters a certain area).", + "location_manageAlarms_empty_description": "Add alarms to be notified when certain conditions are met", "location_manageAlarms_addNewAlarm_actionLabel": "Add Alarm", "location_manageAlarms_lastCheck_description": "Last checked at {date}", "@location_manageAlarms_lastCheck_description": { @@ -676,18 +711,42 @@ } } }, - "location_addAlarm_radiusBased_title": "Add radius-based Alarm", + "location_addAlarm_geo_title": "Add geo-based alarm", + "location_addAlarm_geo_description": "Define a region and get notified when your friend enters or leaves this region", + "location_addAlarm_geo_name_description": "Please enter a name for this region", + "location_addAlarm_geo_help_tapDescription": "Tap on the map to mark the center of your region", + "location_addAlarm_proximity_title": "Add proximity alarm", + "location_addAlarm_proximity_description": "Get notified when your friend is within a certain distance of you", + "location_addAlarm_radiusBased_radius_meters": "Radius: {value} meters", + "location_removeAlarm_title": "Remove Alarm", + "location_removeAlarm_description": "Are you sure you want to remove this alarm?", + "location_removeAlarm_confirm": "Remove", + "@location_addAlarm_radiusBased_radius_meters": { + "placeholders": { + "value": { + "type": "int" + } + } + }, + "location_addAlarm_radiusBased_radius_kilometers": "Radius: {value} km", + "@location_addAlarm_radiusBased_radius_kilometers": { + "placeholders": { + "value": { + "type": "double" + } + } + }, "location_addAlarm_radiusBased_isInScaleMode": "You can now scale the radius by pinching and zooming into the map. Tap on the screen to go back to normal mode.", "location_addAlarm_radiusBased_addLabel": "Select region", "location_addAlarm_radiusBased_help_title": "Radius-based Alarms", "location_addAlarm_radiusBased_help_description": "Radius-based alarms are triggered either when leaving or entering a region. After defining your region, you can decide when your alarm should trigger.", - "location_addAlarm_radiusBased_help_tapDescription": "Tap on the map to mark the center of your region", "location_addAlarm_radiusBased_help_pinchDescription": "Press and hold to enter into scale mode. You can then change the radius by pinching and zooming into the map.", "location_addAlarm_radiusBased_trigger_title": "When should your alarm trigger?", "location_addAlarm_radiusBased_trigger_whenEnter": "Trigger when entering", "location_addAlarm_radiusBased_trigger_whenLeave": "Trigger when leaving", - "location_addAlarm_radiusBased_name_description": "Please enter a name for this region", "location_addAlarm_actionLabel": "Add Alarm", + "location_addAlarm_selectType_title": "Select Alarm Type", + "location_addAlarm_selectType_description": "What kind of alarm do you want to add?", "permissions_openSettings_label": "Open settings", "permissions_openSettings_failed_title": "Settings could not be opened", "permissions_location_askPermission_title": "Grant access to your location", @@ -758,6 +817,15 @@ "locations_values_lastLocation_description": "Last location update", "locations_values_distance_description": "Distance", "locations_values_distance_permissionRequired": "Grant permission", + "locations_values_distance_nearby": "<10 m", + "locations_values_distance_m": "{distance} m", + "@locations_values_distance_m": { + "placeholders": { + "distance": { + "type": "String" + } + } + }, "locations_values_distance_km": "{distance, select, 0 {<1 km} 1 {one km} other {{distance} km}}", "@locations_values_distance_km": { "placeholders": { @@ -785,5 +853,7 @@ } }, "locations_values_batteryState_description": "Battery state", - "locations_values_batteryState_value": "{state, select, full {full} charging {charging} discharging {discharging} other {unknown}}" + "locations_values_batteryState_value": "{state, select, full {full} charging {charging} discharging {discharging} other {unknown}}", + "permissionsMissing_title": "Permissions missing", + "permissionsMissing_message": "Please allow access to your location in the background" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5d33974e..cb007505 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,19 +7,21 @@ 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/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/log_service.dart'; -import 'package:locus/services/manager_service.dart'; -import 'package:locus/services/settings_service.dart'; -import 'package:locus/services/task_service.dart'; -import 'package:locus/services/view_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'; import 'package:provider/provider.dart'; const storage = FlutterSecureStorage(); -final StreamController< - NotificationResponse> selectedNotificationsStream = StreamController - .broadcast(); +final StreamController selectedNotificationsStream = + StreamController.broadcast(); void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -46,7 +48,8 @@ void main() async { isDebuggable: kDebugMode, ); - FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); const initializationSettings = InitializationSettings( android: AndroidInitializationSettings("ic_launcher_foreground"), iOS: DarwinInitializationSettings(), @@ -84,10 +87,12 @@ void main() async { ChangeNotifierProvider(create: (_) => logService), ChangeNotifierProvider( create: (_) => appUpdateService), + ChangeNotifierProvider( + create: (_) => LocationFetchers(viewService.views)), + ChangeNotifierProvider( + create: (_) => CurrentLocationService()), ], child: const App(), ), ); - - registerBackgroundFetch(); } diff --git a/lib/models/log.dart b/lib/models/log.dart index 9036539d..66cd79f8 100644 --- a/lib/models/log.dart +++ b/lib/models/log.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:locus/services/location_alarm_service.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/location_alarm_service/enums.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:uuid/uuid.dart'; const uuid = Uuid(); @@ -226,8 +226,7 @@ class Log { return CreateAlarmData.fromJSON(jsonDecode(payload)); } - factory Log.fromJSON(Map json) => - Log( + factory Log.fromJSON(Map json) => Log( id: json["i"], createdAt: DateTime.parse(json["c"]), type: LogType.values[json["t"]], @@ -235,8 +234,7 @@ class Log { payload: json["p"], ); - Map toJSON() => - { + Map toJSON() => { "i": id, "c": createdAt.toIso8601String(), "t": type.index, @@ -275,24 +273,21 @@ class UpdateLocationData { accuracy: json["c"], tasks: List.from( List>.from(json["t"]).map( - (task) => - UpdatedTaskData( - id: task["i"]!, - name: task["n"]!, - ), + (task) => UpdatedTaskData( + id: task["i"]!, + name: task["n"]!, + ), ), ), ); - Map toJSON() => - { + Map toJSON() => { "o": latitude, "a": longitude, "c": accuracy, "t": List>.from( tasks.map( - (task) => - { + (task) => { "i": task.id, "n": task.name, }, @@ -317,15 +312,13 @@ class CreateTaskData { required this.creationContext, }); - factory CreateTaskData.fromJSON(Map json) => - CreateTaskData( + factory CreateTaskData.fromJSON(Map json) => CreateTaskData( id: json["i"], name: json["n"], creationContext: TaskCreationContext.values[json["c"]], ); - Map toJSON() => - { + Map toJSON() => { "i": id, "n": name, "c": creationContext.index, @@ -341,13 +334,11 @@ class DeleteTaskData { required this.name, }); - factory DeleteTaskData.fromJSON(Map json) => - DeleteTaskData( + factory DeleteTaskData.fromJSON(Map json) => DeleteTaskData( name: json["n"], ); - Map toJSON() => - { + Map toJSON() => { "n": name, }; } @@ -370,8 +361,7 @@ class TaskStatusChangeData { active: json["s"], ); - Map toJSON() => - { + Map toJSON() => { "i": id, "n": name, "s": active, @@ -389,14 +379,12 @@ class StopTaskData { required this.name, }); - factory StopTaskData.fromJSON(Map json) => - StopTaskData( + factory StopTaskData.fromJSON(Map json) => StopTaskData( id: json["i"], name: json["n"], ); - Map toJSON() => - { + Map toJSON() => { "i": id, "n": name, }; @@ -425,8 +413,7 @@ class CreateAlarmData { viewName: json["n"], ); - Map toJSON() => - { + Map toJSON() => { "i": id, "v": viewID, "t": type.index, @@ -449,8 +436,7 @@ class DeleteAlarmData { viewName: json["n"], ); - Map toJSON() => - { + Map toJSON() => { "v": viewID, "n": viewName, }; diff --git a/lib/screens/CheckLocationScreen.dart b/lib/screens/CheckLocationScreen.dart index af9b8b16..80d7835b 100644 --- a/lib/screens/CheckLocationScreen.dart +++ b/lib/screens/CheckLocationScreen.dart @@ -8,7 +8,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:locus/constants/app.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/constants/values.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:locus/utils/helper_sheet.dart'; import 'package:locus/utils/load_status.dart'; import 'package:locus/utils/location/index.dart'; diff --git a/lib/screens/CreateTaskScreen.dart b/lib/screens/CreateTaskScreen.dart index 40127348..e2eee72e 100644 --- a/lib/screens/CreateTaskScreen.dart +++ b/lib/screens/CreateTaskScreen.dart @@ -9,7 +9,8 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' hide PlatformListTile; import 'package:locus/constants/spacing.dart'; import 'package:locus/screens/create_task_screen_widgets/ExampleTasksRoulette.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/utils/theme.dart'; import 'package:locus/widgets/MIUISelectField.dart'; import 'package:locus/widgets/RelaySelectSheet.dart'; @@ -21,7 +22,6 @@ import 'package:provider/provider.dart'; import '../models/log.dart'; import '../services/log_service.dart'; -import '../services/settings_service.dart'; import '../widgets/PlatformListTile.dart'; import '../widgets/WarningText.dart'; @@ -237,269 +237,255 @@ class _CreateTaskScreenState extends State final l10n = AppLocalizations.of(context); final settings = context.read(); - return PlatformScaffold( - appBar: PlatformAppBar( - title: Text(l10n.sharesOverviewScreen_createTask), - material: (_, __) => MaterialAppBarData( - centerTitle: true, - ), - ), - material: (_, __) => MaterialScaffoldData( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(MEDIUM_SPACE), - child: Center( - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SingleChildScrollView( - child: Column( - children: [ - Column( - children: [ - const SizedBox(height: SMALL_SPACE), - Text( - l10n.createTask_title, - style: getSubTitleTextStyle(context), - ), - ], - ), - const SizedBox(height: LARGE_SPACE), - Column( - children: [ - Focus( - onFocusChange: (hasFocus) { - if (!hasFocus) { - return; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(MEDIUM_SPACE), + child: Center( + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SingleChildScrollView( + child: Column( + children: [ + Column( + children: [ + const SizedBox(height: SMALL_SPACE), + Text( + l10n.createTask_title, + style: getSubTitleTextStyle(context), + ), + ], + ), + const SizedBox(height: LARGE_SPACE), + Column( + children: [ + Focus( + onFocusChange: (hasFocus) { + if (!hasFocus) { + return; + } + + setState(() { + showExamples = true; + }); + }, + child: PlatformTextFormField( + controller: _nameController, + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.fields_errors_isEmpty; } - setState(() { - showExamples = true; - }); + if (!StringUtils.isAscii(value)) { + return l10n.fields_errors_invalidCharacters; + } + + return null; }, - child: PlatformTextFormField( - controller: _nameController, - textInputAction: TextInputAction.next, - validator: (value) { - if (value == null || value.isEmpty) { - return l10n.fields_errors_isEmpty; - } - - if (!StringUtils.isAscii(value)) { - return l10n.fields_errors_invalidCharacters; - } - - return null; - }, - keyboardType: TextInputType.name, - autofillHints: const [AutofillHints.name], - material: (_, __) => MaterialTextFormFieldData( - decoration: InputDecoration( - labelText: - l10n.createTask_fields_name_label, - prefixIcon: Icon(context.platformIcons.tag), - ), + keyboardType: TextInputType.name, + autofillHints: const [AutofillHints.name], + material: (_, __) => MaterialTextFormFieldData( + decoration: InputDecoration( + labelText: l10n.createTask_fields_name_label, + prefixIcon: Icon(context.platformIcons.tag), ), - cupertino: (_, __) => - CupertinoTextFormFieldData( - placeholder: - l10n.createTask_fields_name_label, - prefix: Icon(context.platformIcons.tag), + ), + cupertino: (_, __) => CupertinoTextFormFieldData( + placeholder: l10n.createTask_fields_name_label, + prefix: Icon(context.platformIcons.tag), + ), + ) + .animate() + .slide( + duration: IN_DURATION, + curve: Curves.easeOut, + begin: const Offset(0, 0.2), + ) + .fadeIn( + delay: IN_DELAY, + duration: IN_DURATION, + curve: Curves.easeOut, ), - ) - .animate() - .slide( - duration: IN_DURATION, - curve: Curves.easeOut, - begin: const Offset(0, 0.2), - ) - .fadeIn( - delay: IN_DELAY, - duration: IN_DURATION, - curve: Curves.easeOut, - ), + ), + if (showExamples) + ExampleTasksRoulette( + onSelected: (example) { + FocusManager.instance.primaryFocus?.unfocus(); + + _nameController.text = example.name; + _timersController + ..clear() + ..addAll(example.timers); + }, + ), + if (anotherTaskAlreadyExists) ...[ + const SizedBox(height: MEDIUM_SPACE), + WarningText( + l10n.createTask_sameTaskNameAlreadyExists, ), - if (showExamples) - ExampleTasksRoulette( - onSelected: (example) { - FocusManager.instance.primaryFocus?.unfocus(); - - _nameController.text = example.name; - _timersController - ..clear() - ..addAll(example.timers); - }, + ], + const SizedBox(height: MEDIUM_SPACE), + if (settings.isMIUI()) ...[ + MIUISelectField( + label: l10n.createTask_fields_relays_label, + actionText: + l10n.createTask_fields_relays_selectLabel( + _relaysController.relays.length, ), - if (anotherTaskAlreadyExists) ...[ - const SizedBox(height: MEDIUM_SPACE), - WarningText( - l10n.createTask_sameTaskNameAlreadyExists, + icon: const Icon(Icons.dns_rounded), + onPressed: showRelaysSheet, + ).animate().then(delay: IN_DELAY * 4).fadeIn( + delay: IN_DELAY, + duration: IN_DURATION, + curve: Curves.easeOut, + ), + MIUISelectField( + label: l10n.createTask_fields_timers_label, + actionText: + l10n.createTask_fields_timers_selectLabel( + _timersController.timers.length, ), - ], - const SizedBox(height: MEDIUM_SPACE), - if (settings.isMIUI()) ...[ - MIUISelectField( - label: l10n.createTask_fields_relays_label, - actionText: - l10n.createTask_fields_relays_selectLabel( - _relaysController.relays.length, + icon: const Icon(Icons.timer_rounded), + onPressed: showTimersSheet, + ).animate().then(delay: IN_DELAY * 4).fadeIn( + delay: IN_DELAY, + duration: IN_DURATION, + curve: Curves.easeOut, ), - icon: const Icon(Icons.dns_rounded), - onPressed: showRelaysSheet, - ).animate().then(delay: IN_DELAY * 4).fadeIn( - delay: IN_DELAY, - duration: IN_DURATION, - curve: Curves.easeOut, + ] else + Wrap( + alignment: WrapAlignment.spaceEvenly, + spacing: SMALL_SPACE, + crossAxisAlignment: WrapCrossAlignment.center, + direction: Axis.horizontal, + children: [ + PlatformElevatedButton( + material: (_, __) => + MaterialElevatedButtonData( + icon: PlatformWidget( + material: (_, __) => + const Icon(Icons.dns_rounded), + cupertino: (_, __) => const Icon( + CupertinoIcons.list_bullet), + ), ), - MIUISelectField( - label: l10n.createTask_fields_timers_label, - actionText: - l10n.createTask_fields_timers_selectLabel( - _timersController.timers.length, - ), - icon: const Icon(Icons.timer_rounded), - onPressed: showTimersSheet, - ).animate().then(delay: IN_DELAY * 4).fadeIn( - delay: IN_DELAY, - duration: IN_DURATION, - curve: Curves.easeOut, + cupertino: (_, __) => + CupertinoElevatedButtonData( + padding: getSmallButtonPadding(context), ), - ] else - Wrap( - alignment: WrapAlignment.spaceEvenly, - spacing: SMALL_SPACE, - crossAxisAlignment: WrapCrossAlignment.center, - direction: Axis.horizontal, - children: [ - PlatformElevatedButton( - material: (_, __) => - MaterialElevatedButtonData( - icon: PlatformWidget( - material: (_, __) => - const Icon(Icons.dns_rounded), - cupertino: (_, __) => const Icon( - CupertinoIcons.list_bullet), - ), - ), - cupertino: (_, __) => - CupertinoElevatedButtonData( - padding: getSmallButtonPadding(context), - ), - onPressed: showRelaysSheet, - child: Text( - l10n.createTask_fields_relays_selectLabel( - _relaysController.relays.length, - ), + onPressed: showRelaysSheet, + child: Text( + l10n.createTask_fields_relays_selectLabel( + _relaysController.relays.length, ), - ) - .animate() - .then(delay: IN_DELAY * 4) - .slide( - duration: IN_DURATION, - curve: Curves.easeOut, - begin: const Offset(0.2, 0), - ) - .fadeIn( - delay: IN_DELAY, - duration: IN_DURATION, - curve: Curves.easeOut, - ), - PlatformElevatedButton( - material: (_, __) => - MaterialElevatedButtonData( - icon: const Icon(Icons.timer_rounded), + ), + ) + .animate() + .then(delay: IN_DELAY * 4) + .slide( + duration: IN_DURATION, + curve: Curves.easeOut, + begin: const Offset(0.2, 0), + ) + .fadeIn( + delay: IN_DELAY, + duration: IN_DURATION, + curve: Curves.easeOut, ), - cupertino: (_, __) => - CupertinoElevatedButtonData( - padding: getSmallButtonPadding(context), + PlatformElevatedButton( + material: (_, __) => + MaterialElevatedButtonData( + icon: const Icon(Icons.timer_rounded), + ), + cupertino: (_, __) => + CupertinoElevatedButtonData( + padding: getSmallButtonPadding(context), + ), + onPressed: showTimersSheet, + child: Text( + l10n.createTask_fields_timers_selectLabel( + _timersController.timers.length, ), - onPressed: showTimersSheet, - child: Text( - l10n.createTask_fields_timers_selectLabel( - _timersController.timers.length, - ), + ), + ) + .animate() + .then(delay: IN_DELAY * 5) + .slide( + duration: IN_DURATION, + curve: Curves.easeOut, + begin: const Offset(-0.2, 0), + ) + .fadeIn( + delay: IN_DELAY, + duration: IN_DURATION, + curve: Curves.easeOut, ), - ) - .animate() - .then(delay: IN_DELAY * 5) - .slide( - duration: IN_DURATION, - curve: Curves.easeOut, - begin: const Offset(-0.2, 0), - ) - .fadeIn( - delay: IN_DELAY, - duration: IN_DURATION, - curve: Curves.easeOut, - ), - ], - ), - const SizedBox(height: MEDIUM_SPACE), - getScheduleNowWidget(), - ], - ), - ], + ], + ), + const SizedBox(height: MEDIUM_SPACE), + getScheduleNowWidget(), + ], + ), + ], + ), + ), + if (errorMessage != null) ...[ + Text( + errorMessage!, + textAlign: TextAlign.center, + style: getBodyTextTextStyle(context).copyWith( + color: getErrorColor(context), ), ), - if (errorMessage != null) ...[ - Text( - errorMessage!, - textAlign: TextAlign.center, - style: getBodyTextTextStyle(context).copyWith( - color: getErrorColor(context), - ), + const SizedBox(height: MEDIUM_SPACE), + ], + if (_isError) ...[ + Text( + l10n.unknownError, + style: getBodyTextTextStyle(context).copyWith( + color: getErrorColor(context), ), - const SizedBox(height: MEDIUM_SPACE), - ], - if (_isError) ...[ - Text( - l10n.unknownError, - style: getBodyTextTextStyle(context).copyWith( - color: getErrorColor(context), - ), + ), + ], + PlatformElevatedButton( + padding: const EdgeInsets.all(MEDIUM_SPACE), + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + if (_relaysController.relays.isEmpty) { + setState(() { + errorMessage = l10n.createTask_errors_emptyRelays; + }); + return; + } + + createTask(); + }, + child: Text( + l10n.createTask_createLabel, + style: TextStyle( + fontSize: getActionButtonSize(context), ), - ], - PlatformElevatedButton( - padding: const EdgeInsets.all(MEDIUM_SPACE), - onPressed: () { - if (!_formKey.currentState!.validate()) { - return; - } - - if (_relaysController.relays.isEmpty) { - setState(() { - errorMessage = l10n.createTask_errors_emptyRelays; - }); - return; - } - - createTask(); - }, - child: Text( - l10n.createTask_createLabel, - style: TextStyle( - fontSize: getActionButtonSize(context), - ), + ), + ) + .animate() + .then(delay: IN_DELAY * 8) + .slide( + duration: 500.ms, + curve: Curves.easeOut, + begin: const Offset(0, 1.3), + ) + .fadeIn( + duration: 500.ms, + curve: Curves.easeOut, ), - ) - .animate() - .then(delay: IN_DELAY * 8) - .slide( - duration: 500.ms, - curve: Curves.easeOut, - begin: const Offset(0, 1.3), - ) - .fadeIn( - duration: 500.ms, - curve: Curves.easeOut, - ), - ], - ), + ], ), ), ), diff --git a/lib/screens/ImportTaskSheet.dart b/lib/screens/ImportTaskSheet.dart index 146a829a..563b842c 100644 --- a/lib/screens/ImportTaskSheet.dart +++ b/lib/screens/ImportTaskSheet.dart @@ -12,12 +12,12 @@ import 'package:locus/screens/import_task_sheet_widgets/ImportSuccess.dart'; import 'package:locus/screens/import_task_sheet_widgets/NameForm.dart'; import 'package:locus/screens/import_task_sheet_widgets/URLForm.dart'; import 'package:locus/screens/import_task_sheet_widgets/ViewImportOverview.dart'; -import 'package:locus/services/view_service.dart'; +import 'package:locus/services/view_service/index.dart'; import 'package:locus/utils/theme.dart'; import 'package:locus/widgets/ModalSheetContent.dart'; import 'package:provider/provider.dart'; -import '../services/task_service.dart'; +import '../services/task_service/index.dart'; import '../widgets/ModalSheet.dart'; import 'import_task_sheet_widgets/ReceiveViewByBluetooth.dart'; diff --git a/lib/screens/LocationsOverviewScreen.dart b/lib/screens/LocationsOverviewScreen.dart index 411f0fbe..d41315ae 100644 --- a/lib/screens/LocationsOverviewScreen.dart +++ b/lib/screens/LocationsOverviewScreen.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:core'; import 'dart:io'; +import 'dart:math'; import "package:apple_maps_flutter/apple_maps_flutter.dart" as apple_maps; import 'package:collection/collection.dart'; @@ -17,17 +19,23 @@ 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'; import 'package:locus/screens/SharesOverviewScreen.dart'; import 'package:locus/screens/locations_overview_screen_widgets/ActiveSharesSheet.dart'; +import 'package:locus/screens/locations_overview_screen_widgets/LocationFetchers.dart'; import 'package:locus/screens/locations_overview_screen_widgets/OutOfBoundMarker.dart'; import 'package:locus/screens/locations_overview_screen_widgets/ShareLocationSheet.dart'; import 'package:locus/screens/locations_overview_screen_widgets/ViewLocationPopup.dart'; -import 'package:locus/screens/locations_overview_screen_widgets/view_location_fetcher.dart'; -import 'package:locus/services/task_service.dart'; -import 'package:locus/services/view_service.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/settings_service/index.dart'; +import 'package:locus/services/task_service/index.dart'; +import 'package:locus/services/view_service/index.dart'; import 'package:locus/utils/location/get-fallback-location.dart'; import 'package:locus/utils/location/index.dart'; import 'package:locus/utils/navigation.dart'; @@ -35,13 +43,16 @@ import 'package:locus/utils/permissions/has-granted.dart'; import 'package:locus/utils/permissions/request.dart'; import 'package:locus/utils/ui-message/enums.dart'; import 'package:locus/utils/ui-message/show-message.dart'; +import 'package:locus/widgets/CompassMapAction.dart'; import 'package:locus/widgets/FABOpenContainer.dart'; +import 'package:locus/widgets/GoToMyLocationMapAction.dart'; import 'package:locus/widgets/LocationsMap.dart'; import 'package:locus/widgets/LocusFlutterMap.dart'; +import 'package:locus/widgets/MapActionsContainer.dart'; import 'package:locus/widgets/Paper.dart'; -import 'package:locus/widgets/PlatformFlavorWidget.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'; @@ -52,15 +63,13 @@ import '../main.dart'; import '../services/app_update_service.dart'; import '../services/location_point_service.dart'; import '../services/log_service.dart'; -import '../services/manager_service.dart'; -import '../services/settings_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 'ViewDetailScreen.dart'; +import 'ViewDetailsScreen.dart'; import 'locations_overview_screen_widgets/ViewDetailsSheet.dart'; -import 'locations_overview_screen_widgets/constants.dart'; // After this threshold, locations will not be merged together anymore const LOCATION_DETAILS_ZOOM_THRESHOLD = 17; @@ -84,14 +93,10 @@ class _LocationsOverviewScreenState extends State AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin { - late final ViewLocationFetcher _fetchers; MapController? flutterMapController; PopupController? flutterMapPopupController; apple_maps.AppleMapController? appleMapController; - late final AnimationController rotationController; - late Animation rotationAnimation; - bool showFAB = true; bool isNorth = true; @@ -103,12 +108,7 @@ class _LocationsOverviewScreenState extends State // Dummy stream to trigger updates to out of bound markers StreamController mapEventStream = StreamController.broadcast(); - // Since we already listen to the latest position, we will pass it - // manually to `current_location_layer` to avoid it also registering - // extra listeners. - final StreamController - _currentLocationPositionStream = - StreamController.broadcast(); + LocationPointService? visibleLocation; Position? lastPosition; StreamSubscription? _uniLinksStream; @@ -118,10 +118,6 @@ class _LocationsOverviewScreenState extends State // Null = all views String? selectedViewID; - bool _hasGoneToInitialPosition = false; - - final Map> _cachedMergedLocations = {}; - TaskView? get selectedView { if (selectedViewID == null) { return null; @@ -137,55 +133,54 @@ class _LocationsOverviewScreenState extends State void initState() { super.initState(); - _createLocationFetcher(); + final taskService = context.read(); + 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((_) async { + ..addPostFrameCallback((_) { _setLocationFromSettings(); - configureBackgroundFetch(); - - final taskService = context.read(); - final viewService = context.read(); - final logService = context.read(); - final appUpdateService = context.read(); - _fetchers.addListener(_rebuild); - appUpdateService.addListener(_rebuild); - initQuickActions(context); _initUniLinks(); _updateLocaleToSettings(); + _updateBackgroundListeners(); _showUpdateDialogIfRequired(); + _initLiveLocationUpdate(); + locationFetchers.fetchPreviewLocations(); taskService.checkup(logService); - hasGrantedLocationPermission().then((hasGranted) { - if (hasGranted) { - _initLiveLocationUpdate(); + appUpdateService.addListener(_rebuild); + viewService.addListener(_handleViewServiceChange); + + Geolocator.checkPermission().then((status) { + if ({LocationPermission.always, LocationPermission.whileInUse} + .contains(status)) { + updateCurrentPosition( + askPermissions: false, + showErrorMessage: false, + goToPosition: true, + ); } }); - - viewService.addListener(_handleViewServiceChange); }); - _handleViewAlarmChecker(); - _handleNotifications(); - - final settings = context.read(); if (settings.getMapProvider() == MapProvider.openStreetMap) { flutterMapController = MapController(); flutterMapController!.mapEventStream.listen((event) { - if (event is MapEventRotate) { - rotationController.animateTo( - ((event.targetRotation % 360) / 360), - duration: Duration.zero, - ); - - setState(() { - isNorth = (event.targetRotation % 360).abs() < 1; - }); - } - if (event is MapEventWithMove || event is MapEventDoubleTapZoom || event is MapEventScrollWheelZoom) { @@ -200,32 +195,25 @@ class _LocationsOverviewScreenState extends State flutterMapPopupController = PopupController(); } - - rotationController = - AnimationController(vsync: this, duration: Duration.zero); - rotationAnimation = Tween( - begin: 0, - end: 2 * pi, - ).animate(rotationController); } @override dispose() { + final appUpdateService = context.read(); + final locationFetchers = context.read(); + flutterMapController?.dispose(); - _fetchers.dispose(); - _positionStream?.drain(); _viewsAlarmCheckerTimer?.cancel(); _uniLinksStream?.cancel(); - _positionStream?.drain(); mapEventStream.close(); _removeLiveLocationUpdate(); WidgetsBinding.instance.removeObserver(this); - final appUpdateService = context.read(); appUpdateService.removeListener(_rebuild); + locationFetchers.removeLocationUpdatesListener(_rebuild); super.dispose(); } @@ -235,22 +223,30 @@ class _LocationsOverviewScreenState extends State super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.resumed) { - goToCurrentPosition(showErrorMessage: false); + updateCurrentPosition( + askPermissions: false, + goToPosition: false, + showErrorMessage: false, + ); } } void _handleViewServiceChange() { final viewService = context.read(); + final locationFetchers = context.read(); + final newView = viewService.views.last; - _fetchers.addView(newView); + locationFetchers.add(newView); + locationFetchers.fetchPreviewLocations(); } void _setLocationFromSettings() async { final settings = context.read(); - final position = settings.getLastMapLocation(); + final rawPosition = settings.getLastMapLocation(); + final currentLocation = context.read(); - if (position == null) { + if (rawPosition == null) { return; } @@ -258,26 +254,32 @@ class _LocationsOverviewScreenState extends State locationStatus = LocationStatus.stale; }); - _currentLocationPositionStream.add( - LocationMarkerPosition( - latitude: position.latitude, - longitude: position.longitude, - accuracy: position.accuracy, - ), + 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); } List mergeLocationsIfRequired( - final TaskView view, + final List locations, ) { - final locations = _fetchers.locations[view] ?? []; - - if (showDetailedLocations && !disableShowDetailedLocations) { + if (locations.isEmpty) { return locations; } - if (_cachedMergedLocations.containsKey(selectedView)) { - return _cachedMergedLocations[selectedView]!; + if (showDetailedLocations && !disableShowDetailedLocations) { + return locations; } final mergedLocations = mergeLocations( @@ -285,17 +287,9 @@ class _LocationsOverviewScreenState extends State distanceThreshold: LOCATION_MERGE_DISTANCE_THRESHOLD, ); - _cachedMergedLocations[view] = mergedLocations; - return mergedLocations; } - void _createLocationFetcher() { - final viewService = context.read(); - - _fetchers = ViewLocationFetcher(viewService.views)..fetchLocations(); - } - void _rebuild() { if (!mounted) { return; @@ -351,6 +345,41 @@ class _LocationsOverviewScreenState extends State 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; @@ -361,50 +390,19 @@ class _LocationsOverviewScreenState extends State ); _positionStream!.listen((position) async { - _currentLocationPositionStream.add( - LocationMarkerPosition( - latitude: position.latitude, - longitude: position.longitude, - accuracy: position.accuracy, - ), - ); - - _updateLocationToSettings(position); + final taskService = context.read(); + final currentLocation = context.read(); - if (!_hasGoneToInitialPosition) { - if (flutterMapController != null) { - flutterMapController!.move( - LatLng(position.latitude, position.longitude), - INITIAL_LOCATION_FETCHED_ZOOM_LEVEL, - ); - } + currentLocation.updateCurrentPosition(position); - // Print statement is required to work - print(appleMapController); - if (appleMapController != null) { - if (_hasGoneToInitialPosition) { - appleMapController!.animateCamera( - apple_maps.CameraUpdate.newLatLng( - apple_maps.LatLng(position.latitude, position.longitude), - ), - ); - } else { - appleMapController!.moveCamera( - apple_maps.CameraUpdate.newLatLng( - apple_maps.LatLng(position.latitude, position.longitude), - ), - ); - } - } - _hasGoneToInitialPosition = true; - } + _checkViewAlarms(position); + _updateLocationToSettings(position); setState(() { lastPosition = position; locationStatus = LocationStatus.active; }); - final taskService = context.read(); final runningTasks = await taskService.getRunningTasks().toList(); if (runningTasks.isEmpty) { @@ -414,7 +412,8 @@ class _LocationsOverviewScreenState extends State final locationData = await LocationPointService.fromPosition(position); for (final task in runningTasks) { - await task.publishLocation( + await task.publisher.publishOutstandingPositions(); + await task.publisher.publishLocation( locationData.copyWithDifferentId(), ); } @@ -484,17 +483,10 @@ class _LocationsOverviewScreenState extends State const Duration(minutes: 1), (_) { final viewService = context.read(); - final l10n = AppLocalizations.of(context); if (viewService.viewsWithAlarms.isEmpty) { return; } - - checkViewAlarms( - l10n: l10n, - views: viewService.viewsWithAlarms, - viewService: viewService, - ); }, ); } @@ -518,19 +510,22 @@ class _LocationsOverviewScreenState extends State Navigator.of(context).push( NativePageRoute( context: context, - builder: (_) => ViewDetailScreen( + builder: (_) => ViewDetailsScreen( view: viewService.getViewById(data["taskViewID"]), ), ), ); + break; + case NotificationActionType.openPermissionsSettings: + openAppSettings(); + break; } } catch (error) { - FlutterLogs.logErrorTrace( + FlutterLogs.logError( LOG_TAG, "Notification", - "Error handling notification.", - error as Error, + "Error handling notification: $error", ); } }); @@ -592,10 +587,47 @@ class _LocationsOverviewScreenState extends State } } - void goToCurrentPosition({ - final bool askPermissions = false, - final bool showErrorMessage = true, + Future _animateToPosition( + final Position position, + ) async { + if (flutterMapController != null) { + final zoom = max(15, flutterMapController!.zoom).toDouble(); + + flutterMapController?.move( + LatLng( + position.latitude, + position.longitude, + ), + zoom, + ); + } + + if (appleMapController != null) { + final zoom = max( + 15, + (await appleMapController!.getZoomLevel())!, + ).toDouble(); + + appleMapController?.animateCamera( + apple_maps.CameraUpdate.newCameraPosition( + apple_maps.CameraPosition( + target: apple_maps.LatLng( + position.latitude, + position.longitude, + ), + zoom: zoom, + ), + ), + ); + } + } + + void updateCurrentPosition({ + required final bool askPermissions, + required final bool goToPosition, + required final bool showErrorMessage, }) async { + final currentLocation = context.read(); final previousValue = locationStatus; setState(() { @@ -620,29 +652,6 @@ class _LocationsOverviewScreenState extends State return; } - _initLiveLocationUpdate(); - - if (lastPosition != null) { - if (flutterMapController != null) { - flutterMapController?.move( - LatLng(lastPosition!.latitude, lastPosition!.longitude), - 13, - ); - } - - if (appleMapController != null) { - appleMapController?.animateCamera( - apple_maps.CameraUpdate.newCameraPosition( - apple_maps.CameraPosition( - target: apple_maps.LatLng( - lastPosition!.latitude, lastPosition!.longitude), - zoom: 13, - ), - ), - ); - } - } - FlutterLogs.logInfo( LOG_TAG, "LocationOverviewScreen", @@ -652,13 +661,11 @@ class _LocationsOverviewScreenState extends State try { final latestPosition = await getCurrentPosition(); - _currentLocationPositionStream.add( - LocationMarkerPosition( - latitude: latestPosition.latitude, - longitude: latestPosition.longitude, - accuracy: latestPosition.accuracy, - ), - ); + currentLocation.updateCurrentPosition(latestPosition); + + if (goToPosition) { + _animateToPosition(latestPosition); + } setState(() { lastPosition = latestPosition; @@ -681,12 +688,13 @@ class _LocationsOverviewScreenState extends State final l10n = AppLocalizations.of(context); - showMessage( - context, - l10n.unknownError, - type: MessageType.error, - ); - return; + if (showErrorMessage) { + showMessage( + context, + l10n.unknownError, + type: MessageType.error, + ); + } } } @@ -704,8 +712,9 @@ class _LocationsOverviewScreenState extends State }[locationStatus]!; return CurrentLocationLayer( - positionStream: _currentLocationPositionStream.stream, - followOnLocationUpdate: FollowOnLocationUpdate.always, + positionStream: + context.read().locationMarkerStream, + followOnLocationUpdate: FollowOnLocationUpdate.never, style: LocationMarkerStyle( marker: DefaultLocationMarker( color: color, @@ -719,6 +728,24 @@ class _LocationsOverviewScreenState extends State Widget buildMap() { final settings = context.read(); final viewService = context.read(); + 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)); if (settings.getMapProvider() == MapProvider.apple) { return apple_maps.AppleMap( @@ -741,53 +768,42 @@ class _LocationsOverviewScreenState extends State appleMapController = controller; if (lastPosition != null) { - appleMapController?.moveCamera( - apple_maps.CameraUpdate.newCameraPosition( - apple_maps.CameraPosition( - target: apple_maps.LatLng( - lastPosition!.latitude, - lastPosition!.longitude, - ), - zoom: 13, - ), - ), - ); - - _hasGoneToInitialPosition = true; + _animateToPosition(lastPosition!); } }, circles: viewService.views .where( (view) => selectedViewID == null || view.id == selectedViewID) .map( - (view) => mergeLocationsIfRequired(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(), + (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(), ) .expand((element) => element) .toSet(), polylines: Set.from( - _fetchers.locations.entries - .where((entry) => - selectedViewID == null || entry.key.id == selectedViewID) + locationFetchers.fetchers + .where((fetcher) => + selectedViewID == null || fetcher.view.id == selectedViewID) .map( - (entry) { - final view = entry.key; + (fetcher) { + final view = fetcher.view; return apple_maps.Polyline( polylineId: apple_maps.PolylineId(view.id), - color: entry.key.color.withOpacity(0.9), + color: view.color.withOpacity(0.9), width: 10, jointType: apple_maps.JointType.round, polylineCap: apple_maps.Cap.roundCap, @@ -798,7 +814,9 @@ class _LocationsOverviewScreenState extends State selectedViewID = view.id; }); }, - points: mergeLocationsIfRequired(entry.key) + // TODO + points: mergeLocationsIfRequired( + locationFetchers.getLocations(view)) .reversed .map( (location) => apple_maps.LatLng( @@ -814,39 +832,62 @@ class _LocationsOverviewScreenState extends State ); } + final lastCenter = settings.getLastMapLocation()?.toLatLng(); + final colorOpacityMultiplier = selectedViewID == null ? 1.0 : .1; return LocusFlutterMap( - mapController: flutterMapController, - children: [ + flutterMapController: flutterMapController, + flutterMapOptions: MapOptions( + maxZoom: 18, + minZoom: 2, + center: lastCenter ?? getFallbackLocation(context), + zoom: lastCenter == null ? FALLBACK_LOCATION_ZOOM_LEVEL : 13, + ), + flutterChildren: [ CircleLayer( - circles: viewService.views.reversed - .where( - (view) => selectedViewID == null || view.id == selectedViewID) - .map( - (view) => mergeLocationsIfRequired(view) - .mapIndexed( - (index, location) => CircleMarker( - radius: location.accuracy, - useRadiusInMeter: true, - point: LatLng(location.latitude, location.longitude), - borderStrokeWidth: 1, - color: view.color.withOpacity(.1), - borderColor: view.color, - ), - ) - .toList(), - ) - .expand((element) => element) - .toList(), + 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), + ); + }) + .toList() + .cast(), ), + if (visibleLocation != null) + CircleLayer( + circles: [ + CircleMarker( + radius: visibleLocation!.accuracy, + useRadiusInMeter: true, + point: LatLng( + visibleLocation!.latitude, + visibleLocation!.longitude, + ), + borderStrokeWidth: 5, + color: selectedView!.color.withOpacity(.2), + borderColor: selectedView!.color, + ) + ], + ), PolylineLayer( polylines: List.from( - _fetchers.locations.entries - .where((entry) => - selectedViewID == null || entry.key.id == selectedViewID) + locationFetchers.fetchers + .where((fetcher) => + selectedViewID == null || fetcher.view.id == selectedViewID) .map( - (entry) { - final view = entry.key; - final locations = mergeLocationsIfRequired(entry.key); + (fetcher) { + final view = fetcher.view; + final locations = mergeLocationsIfRequired( + locationFetchers.getLocations(view), + ); return Polyline( color: view.color.withOpacity(0.9), @@ -896,9 +937,9 @@ class _LocationsOverviewScreenState extends State markers: viewService.views .where((view) => (selectedViewID == null || view.id == selectedViewID) && - _fetchers.locations[view]?.last != null) + locationFetchers.getLocations(view).isNotEmpty) .map((view) { - final latestLocation = _fetchers.locations[view]!.last; + final latestLocation = locationFetchers.getLocations(view).last; return Marker( key: Key(view.id), @@ -928,18 +969,20 @@ class _LocationsOverviewScreenState extends State } Widget buildOutOfBoundsMarkers() { + final locationFetchers = context.read(); + return Stack( - children: _fetchers.views - .where((view) => - (_fetchers.locations[view]?.isNotEmpty ?? false) && - (selectedViewID == null || selectedViewID == view.id)) + children: locationFetchers.fetchers + .where((fetcher) => + (selectedViewID == null || fetcher.view.id == selectedViewID) && + fetcher.sortedLocations.isNotEmpty) .map( - (view) => OutOfBoundMarker( - lastViewLocation: _fetchers.locations[view]!.last, + (fetcher) => OutOfBoundMarker( + lastViewLocation: fetcher.sortedLocations.last, onTap: () { - showViewLocations(view); + showViewLocations(fetcher.view); }, - view: view, + view: fetcher.view, updateStream: mapEventStream.stream, appleMapController: appleMapController, flutterMapController: flutterMapController, @@ -949,8 +992,12 @@ class _LocationsOverviewScreenState extends State ); } - void showViewLocations(final TaskView view, - {final bool jumpToLatestLocation = true}) async { + void showViewLocations( + final TaskView view, { + final bool jumpToLatestLocation = true, + }) async { + final locationFetchers = context.read(); + setState(() { showFAB = false; selectedViewID = view.id; @@ -960,12 +1007,13 @@ class _LocationsOverviewScreenState extends State return; } - final latestLocation = _fetchers.locations[view]?.last; + final locations = locationFetchers.getLocations(view); - if (latestLocation == null) { + if (locations.isEmpty) { return; } + final latestLocation = locations.last; if (flutterMapController != null) { flutterMapController!.move( LatLng(latestLocation.latitude, latestLocation.longitude), @@ -980,7 +1028,7 @@ class _LocationsOverviewScreenState extends State latestLocation.latitude, latestLocation.longitude, ), - zoom: (await appleMapController!.getZoomLevel()) ?? 13.0, + zoom: (await appleMapController!.getZoomLevel()) ?? 15.0, ), ), ); @@ -1060,6 +1108,7 @@ class _LocationsOverviewScreenState extends State Navigator.pop(context); setState(() { selectedViewID = null; + visibleLocation = null; }); }, ) @@ -1118,6 +1167,7 @@ class _LocationsOverviewScreenState extends State setState(() { showFAB = true; selectedViewID = null; + visibleLocation = null; }); return; } @@ -1166,6 +1216,7 @@ class _LocationsOverviewScreenState extends State Navigator.pop(context); setState(() { selectedViewID = null; + visibleLocation = null; }); }, ) @@ -1199,19 +1250,18 @@ class _LocationsOverviewScreenState extends State } LocationPointService? get lastLocation { - if (selectedView == null) { - return null; - } + final locationFetchers = context.read(); - if (_fetchers.locations[selectedView!] == null) { + if (selectedView == null) { return null; } - if (_fetchers.locations[selectedView!]!.isEmpty) { + final locations = locationFetchers.getLocations(selectedView!); + if (locations.isEmpty) { return null; } - return _fetchers.locations[selectedView!]!.last; + return locations.last; } void importLocation() { @@ -1244,7 +1294,8 @@ class _LocationsOverviewScreenState extends State } final settings = context.read(); - final link = await (task as Task).generateLink(settings.getServerHost()); + final link = + await (task as Task).publisher.generateLink(settings.getServerHost()); // Copy to clipboard await Clipboard.setData(ClipboardData(text: link)); @@ -1263,148 +1314,68 @@ class _LocationsOverviewScreenState extends State Widget buildMapActions() { const margin = 10.0; const dimension = 50.0; - const diff = FAB_SIZE - dimension; final l10n = AppLocalizations.of(context); final settings = context.watch(); final shades = getPrimaryColorShades(context); if (settings.getMapProvider() == MapProvider.openStreetMap) { - return Positioned( - // Add half the difference to center the button - right: FAB_MARGIN + diff / 2, - bottom: FAB_SIZE + - FAB_MARGIN + - (isCupertino(context) ? LARGE_SPACE : SMALL_SPACE), - child: Column( - children: [ - AnimatedScale( - scale: showDetailedLocations ? 1 : 0, - duration: - showDetailedLocations ? 1200.milliseconds : 100.milliseconds, - curve: showDetailedLocations ? Curves.elasticOut : Curves.easeIn, - child: Tooltip( - message: disableShowDetailedLocations - ? l10n.locationsOverview_mapAction_detailedLocations_show - : l10n.locationsOverview_mapAction_detailedLocations_hide, - preferBelow: false, - margin: const EdgeInsets.only(bottom: margin), - child: SizedBox.square( - dimension: dimension, - child: Center( - child: Paper( - width: null, - borderRadius: BorderRadius.circular(HUGE_SPACE), - padding: EdgeInsets.zero, - child: IconButton( - color: shades[400], - icon: Icon(disableShowDetailedLocations - ? MdiIcons.mapMarkerMultipleOutline - : MdiIcons.mapMarkerMultiple), - onPressed: () { - setState(() { - disableShowDetailedLocations = - !disableShowDetailedLocations; - }); - }, - ), - ), - ), - ), - ), - ), - const SizedBox(height: SMALL_SPACE), - Tooltip( - message: l10n.locationsOverview_mapAction_alignNorth, + return MapActionsContainer( + children: [ + AnimatedScale( + scale: showDetailedLocations ? 1 : 0, + duration: + showDetailedLocations ? 1200.milliseconds : 100.milliseconds, + curve: showDetailedLocations ? Curves.elasticOut : Curves.easeIn, + child: Tooltip( + message: disableShowDetailedLocations + ? l10n.locationsOverview_mapAction_detailedLocations_show + : l10n.locationsOverview_mapAction_detailedLocations_hide, preferBelow: false, margin: const EdgeInsets.only(bottom: margin), child: SizedBox.square( dimension: dimension, child: Center( - child: PlatformWidget( - material: (context, _) => Paper( - width: null, - borderRadius: BorderRadius.circular(HUGE_SPACE), - padding: EdgeInsets.zero, - child: IconButton( - color: isNorth ? shades[200] : shades[400], - icon: AnimatedBuilder( - animation: rotationAnimation, - builder: (context, child) => Transform.rotate( - angle: rotationAnimation.value, - child: child, - ), - child: PlatformFlavorWidget( - material: (context, _) => Transform.rotate( - angle: -pi / 4, - child: const Icon(MdiIcons.compass), - ), - cupertino: (context, _) => - const Icon(CupertinoIcons.location_north_fill), - ), - ), - onPressed: () { - if (flutterMapController != null) { - flutterMapController!.rotate(0); - } - }, - ), - ), - cupertino: (context, _) => CupertinoButton( - color: isNorth ? shades[200] : shades[400], - padding: EdgeInsets.zero, - borderRadius: BorderRadius.circular(HUGE_SPACE), + child: Paper( + width: null, + borderRadius: BorderRadius.circular(HUGE_SPACE), + padding: EdgeInsets.zero, + child: PlatformIconButton( + color: shades[400], + icon: Icon(disableShowDetailedLocations + ? MdiIcons.mapMarkerMultipleOutline + : MdiIcons.mapMarkerMultiple), onPressed: () { - if (flutterMapController != null) { - flutterMapController!.rotate(0); - } + setState(() { + disableShowDetailedLocations = + !disableShowDetailedLocations; + }); }, - child: AnimatedBuilder( - animation: rotationAnimation, - builder: (context, child) => Transform.rotate( - angle: rotationAnimation.value, - child: child, - ), - child: const Icon(CupertinoIcons.location_north_fill), - ), ), ), ), ), ), - const SizedBox(height: SMALL_SPACE), - Tooltip( - message: l10n.locationsOverview_mapAction_goToCurrentPosition, - preferBelow: false, - margin: const EdgeInsets.only(bottom: margin), - child: SizedBox.square( - dimension: dimension, - child: Center( - child: PlatformWidget( - material: (context, _) => Paper( - width: null, - borderRadius: BorderRadius.circular(HUGE_SPACE), - padding: EdgeInsets.zero, - child: IconButton( - color: shades[400], - icon: const Icon(Icons.my_location), - onPressed: () => - goToCurrentPosition(askPermissions: true), - ), - ), - cupertino: (context, _) => CupertinoButton( - color: shades[400], - padding: EdgeInsets.zero, - onPressed: () => - goToCurrentPosition(askPermissions: true), - child: const Icon(Icons.my_location), - ), - ), - ), - ), - ), - ], - ), + ), + const SizedBox(height: SMALL_SPACE), + CompassMapAction( + onAlignNorth: () { + flutterMapController!.rotate(0); + }, + mapController: flutterMapController!, + ), + const SizedBox(height: SMALL_SPACE), + GoToMyLocationMapAction( + animate: locationStatus == LocationStatus.fetching, + onGoToMyLocation: () { + updateCurrentPosition( + askPermissions: true, + goToPosition: true, + showErrorMessage: true, + ); + }, + ), + ], ); } @@ -1416,6 +1387,7 @@ class _LocationsOverviewScreenState extends State super.build(context); final settings = context.watch(); + final locationFetchers = context.watch(); final l10n = AppLocalizations.of(context); return PlatformScaffold( @@ -1500,11 +1472,16 @@ class _LocationsOverviewScreenState extends State buildMapActions(), ViewDetailsSheet( view: selectedView, - locations: _fetchers.locations[selectedView], + locations: selectedViewID == null + ? [] + : locationFetchers.getLocations(selectedView!), onGoToPosition: (position) { if (flutterMapController != null) { - flutterMapController! - .move(position, flutterMapController!.zoom); + // Get zoom based of accuracy + final radius = position.accuracy / 200; + final zoom = (16 - log(radius) / log(2)).toDouble(); + + flutterMapController!.move(position.asLatLng(), zoom); } if (appleMapController != null) { @@ -1518,6 +1495,11 @@ class _LocationsOverviewScreenState extends State ); } }, + onVisibleLocationChange: (location) { + setState(() { + visibleLocation = location; + }); + }, ), ActiveSharesSheet( visible: selectedViewID == null, @@ -1536,6 +1518,7 @@ class _LocationsOverviewScreenState extends State setState(() { showFAB = true; selectedViewID = null; + visibleLocation = null; }); createNewQuickLocationShare(); diff --git a/lib/screens/SettingsScreen.dart b/lib/screens/SettingsScreen.dart index e66cf74b..c7c6782d 100644 --- a/lib/screens/SettingsScreen.dart +++ b/lib/screens/SettingsScreen.dart @@ -18,7 +18,9 @@ import 'package:locus/screens/settings_screen_widgets/ImportSheet.dart'; import 'package:locus/screens/settings_screen_widgets/MentionTile.dart'; import 'package:locus/screens/settings_screen_widgets/ServerOriginSheet.dart'; import 'package:locus/screens/settings_screen_widgets/TransferSenderScreen.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/screens/settings_screen_widgets/UseRealtimeUpdatesTile.dart'; +import 'package:locus/services/manager_service/task.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/utils/PageRoute.dart'; import 'package:locus/utils/import_export_handler.dart'; import 'package:locus/utils/theme.dart'; @@ -35,14 +37,15 @@ import 'package:settings_ui/settings_ui.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../services/settings_service.dart'; -import '../services/view_service.dart'; +import 'package:locus/services/settings_service/index.dart'; +import '../services/view_service/index.dart'; import '../utils/file.dart'; import '../utils/platform.dart'; import '../widgets/PlatformListTile.dart'; import '../widgets/RelaySelectSheet.dart'; const storage = FlutterSecureStorage(); +const OFF_OPACITY = 0.4; class SettingsScreen extends StatefulWidget { const SettingsScreen({ @@ -146,12 +149,14 @@ class _SettingsScreenState extends State { ), settingsSectionBackground: platformThemeData( context, - material: (data) => settings.isMIUI() + material: (data) => + settings.isMIUI() ? data.scaffoldBackgroundColor : data.dialogBackgroundColor, - cupertino: (data) => HSLColor.fromColor(data.barBackgroundColor) - .withLightness(.2) - .toColor(), + cupertino: (data) => + HSLColor.fromColor(data.barBackgroundColor) + .withLightness(.2) + .toColor(), ), titleTextColor: platformThemeData( context, @@ -164,7 +169,7 @@ class _SettingsScreenState extends State { cupertino: (data) => data.textTheme.navTitleTextStyle.color, ), tileDescriptionTextColor: - settings.isMIUI() ? const Color(0xFF808080) : null, + settings.isMIUI() ? const Color(0xFF808080) : null, ); } @@ -192,452 +197,475 @@ class _SettingsScreenState extends State { child: Column( children: [ SettingsList( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - lightTheme: getTheme(), - darkTheme: getTheme(), - sections: [ - SettingsSection( - title: Text(l10n.settingsScreen_section_design), - tiles: [ - SettingsColorPicker( - title: l10n.settingsScreen_setting_primaryColor_label, - value: settings.primaryColor, - leading: PlatformWidget( - material: (_, __) => - const Icon(Icons.color_lens_rounded), - cupertino: (_, __) => - const Icon(CupertinoIcons.color_filter), - ), - onUpdate: (value) { - settings.setPrimaryColor(value); - settings.save(); - }, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + lightTheme: getTheme(), + darkTheme: getTheme(), + sections: [ + SettingsSection( + title: Text(l10n.settingsScreen_section_design), + tiles: [ + SettingsColorPicker( + title: l10n.settingsScreen_setting_primaryColor_label, + value: settings.primaryColor, + leading: PlatformWidget( + material: (_, __) => + const Icon(Icons.color_lens_rounded), + cupertino: (_, __) => + const Icon(CupertinoIcons.color_filter), ), - if (Platform.isAndroid) - SettingsDropdownTile( - title: Text( - l10n.settingsScreen_settings_androidTheme_label, - ), - values: AndroidTheme.values, - value: settings.androidTheme, - leading: const Icon(Icons.design_services_rounded), - textMapping: { - AndroidTheme.materialYou: l10n - .settingsScreen_settings_androidTheme_materialYou, - AndroidTheme.miui: l10n - .settingsScreen_settings_androidTheme_miui, - }, - onUpdate: (newValue) { - settings.setAndroidTheme(newValue); - settings.save(); - }, + onUpdate: (value) { + settings.setPrimaryColor(value); + settings.save(); + }, + ), + if (Platform.isAndroid) + SettingsDropdownTile( + title: Text( + l10n.settingsScreen_settings_androidTheme_label, ), - ], - ), - SettingsSection( - title: Text(l10n.settingsScreen_section_privacy), - tiles: [ - SettingsTile.switchTile( - initialValue: settings.automaticallyLookupAddresses, - onToggle: (newValue) { - settings.setAutomaticallyLookupAddresses(newValue); + values: AndroidTheme.values, + value: settings.androidTheme, + leading: const Icon(Icons.design_services_rounded), + textMapping: { + AndroidTheme.materialYou: l10n + .settingsScreen_settings_androidTheme_materialYou, + AndroidTheme.miui: + l10n.settingsScreen_settings_androidTheme_miui, + }, + onUpdate: (newValue) { + settings.setAndroidTheme(newValue); settings.save(); }, - title: Text(l10n - .settingsScreen_setting_lookupAddresses_label), - description: Text(l10n - .settingsScreen_setting_lookupAddresses_description), ), + ], + ), + SettingsSection( + title: Text(l10n.settingsScreen_section_privacy), + tiles: [ + SettingsTile.switchTile( + initialValue: settings.automaticallyLookupAddresses, + onToggle: (newValue) { + settings.setAutomaticallyLookupAddresses(newValue); + settings.save(); + }, + title: Text( + l10n.settingsScreen_setting_lookupAddresses_label), + description: Text(l10n + .settingsScreen_setting_lookupAddresses_description), + ), + SettingsDropdownTile( + title: Text( + l10n.settingsScreen_settings_geocoderProvider_label, + ), + values: SettingsService.isSystemGeocoderAvailable() + ? GeocoderProvider.values + : GeocoderProvider.values + .where((element) => + element != GeocoderProvider.system) + .toList(), + textMapping: { + GeocoderProvider.system: l10n + .settingsScreen_settings_geocoderProvider_system, + GeocoderProvider.geocodeMapsCo: l10n + .settingsScreen_settings_geocoderProvider_geocodeMapsCo, + GeocoderProvider.nominatim: l10n + .settingsScreen_settings_geocoderProvider_nominatim, + }, + value: settings.geocoderProvider, + leading: Icon(context.platformIcons.search), + onUpdate: (newValue) { + settings.setGeocoderProvider(newValue); + settings.save(); + }, + ), + if (isPlatformApple()) SettingsDropdownTile( title: Text( - l10n.settingsScreen_settings_geocoderProvider_label, + l10n.settingsScreen_settings_mapProvider_label, ), - values: SettingsService.isSystemGeocoderAvailable() - ? GeocoderProvider.values - : GeocoderProvider.values - .where((element) => - element != GeocoderProvider.system) - .toList(), + values: MapProvider.values, textMapping: { - GeocoderProvider.system: l10n - .settingsScreen_settings_geocoderProvider_system, - GeocoderProvider.geocodeMapsCo: l10n - .settingsScreen_settings_geocoderProvider_geocodeMapsCo, - GeocoderProvider.nominatim: l10n - .settingsScreen_settings_geocoderProvider_nominatim, + MapProvider.apple: + l10n.settingsScreen_settings_mapProvider_apple, + MapProvider.openStreetMap: l10n + .settingsScreen_settings_mapProvider_openStreetMap, }, - value: settings.geocoderProvider, - leading: Icon(context.platformIcons.search), + leading: PlatformFlavorWidget( + material: (_, __) => const Icon(Icons.map_rounded), + cupertino: (_, __) => + const Icon(CupertinoIcons.map), + ), + value: settings.mapProvider, onUpdate: (newValue) { - settings.setGeocoderProvider(newValue); + settings.setMapProvider(newValue); settings.save(); }, ), - if (isPlatformApple()) - SettingsDropdownTile( - title: Text( - l10n.settingsScreen_settings_mapProvider_label, - ), - values: MapProvider.values, - textMapping: { - MapProvider.apple: l10n - .settingsScreen_settings_mapProvider_apple, - MapProvider.openStreetMap: l10n - .settingsScreen_settings_mapProvider_openStreetMap, - }, - leading: PlatformFlavorWidget( - material: (_, __) => - const Icon(Icons.map_rounded), - cupertino: (_, __) => - const Icon(CupertinoIcons.map), - ), - value: settings.mapProvider, - onUpdate: (newValue) { - settings.setMapProvider(newValue); - settings.save(); - }, - ), - if (hasBiometricsAvailable) - SettingsTile.switchTile( - initialValue: - settings.requireBiometricAuthenticationOnStart, - onToggle: (newValue) async { - final auth = LocalAuthentication(); - - try { - final hasAuthenticated = - await auth.authenticate( - localizedReason: l10n - .settingsScreen_setting_requireBiometricAuth_requireNowReason, - options: const AuthenticationOptions( - stickyAuth: true, - biometricOnly: true, - ), - ); + if (hasBiometricsAvailable) + SettingsTile.switchTile( + initialValue: + settings.requireBiometricAuthenticationOnStart, + onToggle: (newValue) async { + final auth = LocalAuthentication(); + + try { + final hasAuthenticated = await auth.authenticate( + localizedReason: l10n + .settingsScreen_setting_requireBiometricAuth_requireNowReason, + options: const AuthenticationOptions( + stickyAuth: true, + biometricOnly: true, + ), + ); - if (!hasAuthenticated) { - throw Exception( - "Authenticated failed.", - ); - } - - settings - .setRequireBiometricAuthenticationOnStart( - newValue); - await settings.save(); - } catch (error) { - FlutterLogs.logInfo( - LOG_TAG, - "Settings", - "Error while authenticating biometrics: $error", + if (!hasAuthenticated) { + throw Exception( + "Authenticated failed.", ); + } - if (!mounted) { - return; - } + settings.setRequireBiometricAuthenticationOnStart( + newValue); + await settings.save(); + } catch (error) { + FlutterLogs.logInfo( + LOG_TAG, + "Settings", + "Error while authenticating biometrics: $error", + ); - showMessage( - context, - l10n.unknownError, - type: MessageType.error, - ); + if (!mounted) { + return; } - }, - title: Text(l10n - .settingsScreen_setting_requireBiometricAuth_label), - description: Text(l10n - .settingsScreen_setting_requireBiometricAuth_description), - leading: PlatformFlavorWidget( - material: (_, __) => - const Icon(Icons.fingerprint_rounded), - cupertino: (_, __) => const Icon( - CupertinoIcons.shield_lefthalf_fill), - ), - ) - ], - ), - SettingsSection( - title: Text(l10n.settingsScreen_section_defaults), - tiles: [ - SettingsTile( - title: - Text(l10n.settingsScreen_settings_relays_label), - trailing: PlatformTextButton( - child: Text( - l10n.settingsScreen_settings_relays_selectLabel( - _relayController.relays.length, - ), - ), - material: (_, __) => MaterialTextButtonData( - icon: const Icon(Icons.dns_rounded), - ), - onPressed: () async { - await showPlatformModalSheet( - context: context, - material: MaterialModalSheetData( - isScrollControlled: true, - isDismissible: true, - backgroundColor: Colors.transparent, - ), - builder: (_) => RelaySelectSheet( - controller: _relayController, - ), + + showMessage( + context, + l10n.unknownError, + type: MessageType.error, ); - }, - ), - ), - SettingsTile( - title: Text( - l10n.settingsScreen_settings_serverOrigin_label, + } + }, + title: Text(l10n + .settingsScreen_setting_requireBiometricAuth_label), + description: Text(l10n + .settingsScreen_setting_requireBiometricAuth_description), + leading: PlatformFlavorWidget( + material: (_, __) => + const Icon(Icons.fingerprint_rounded), + cupertino: (_, __) => + const Icon(CupertinoIcons.shield_lefthalf_fill), ), - description: Text( - l10n.settingsScreen_settings_serverOrigin_description, + ) + ], + ), + SettingsSection( + title: Text(l10n.settingsScreen_section_defaults), + tiles: [ + SettingsTile( + title: Text(l10n.settingsScreen_settings_relays_label), + trailing: PlatformTextButton( + child: Text( + l10n.settingsScreen_settings_relays_selectLabel( + _relayController.relays.length, + ), ), - trailing: Text(settings.serverOrigin), - onPressed: (_) async { - final newHostName = await showPlatformModalSheet( + material: (_, __) => + MaterialTextButtonData( + icon: const Icon(Icons.dns_rounded), + ), + onPressed: () async { + await showPlatformModalSheet( context: context, material: MaterialModalSheetData( isScrollControlled: true, isDismissible: true, backgroundColor: Colors.transparent, ), - builder: (_) => const ServerOriginSheet(), + builder: (_) => + RelaySelectSheet( + controller: _relayController, + ), ); - - if (newHostName != null) { - settings.serverServerOrigin(newHostName); - settings.save(); - } - }, - ) - ], - ), - SettingsSection( - title: Text(l10n.settingsScreen_sections_misc), - tiles: [ - SettingsTile.switchTile( - initialValue: settings.showHints, - onToggle: (newValue) { - settings.setShowHints(newValue); - settings.save(); }, - title: Text( - l10n.settingsScreen_settings_showHints_label, - ), - description: Text( - l10n.settingsScreen_settings_showHints_description, - ), - leading: Icon(context.platformIcons.info), ), - SettingsTile.switchTile( - initialValue: settings.alwaysUseBatterySaveMode, - onToggle: (newValue) { - settings.setAlwaysUseBatterySaveMode(newValue); + ), + SettingsTile( + title: Text( + l10n.settingsScreen_settings_serverOrigin_label, + ), + description: Text( + l10n.settingsScreen_settings_serverOrigin_description, + ), + trailing: Text(settings.serverOrigin), + onPressed: (_) async { + final newHostName = await showPlatformModalSheet( + context: context, + material: MaterialModalSheetData( + isScrollControlled: true, + isDismissible: true, + backgroundColor: Colors.transparent, + ), + builder: (_) => const ServerOriginSheet(), + ); + + if (newHostName != null) { + settings.serverServerOrigin(newHostName); settings.save(); - }, - title: Text( - l10n.settingsScreen_settings_alwaysUseBatterySaveMode_label, - ), - description: Text( - l10n.settingsScreen_settings_alwaysUseBatterySaveMode_description, - ), + } + }, + ) + ], + ), + SettingsSection( + title: Text(l10n.settingsScreen_sections_misc), + tiles: [ + SettingsTile.switchTile( + initialValue: settings.showHints, + onToggle: (newValue) { + settings.setShowHints(newValue); + settings.save(); + }, + title: Text( + l10n.settingsScreen_settings_showHints_label, ), - SettingsTile.navigation( - title: Text( - l10n.settingsScreen_settings_importExport_exportFile, + description: Text( + l10n.settingsScreen_settings_showHints_description, + ), + leading: Icon(context.platformIcons.info), + ), + const UseRealtimeUpdatesTile(), + SettingsTile.switchTile( + initialValue: settings.alwaysUseBatterySaveMode, + onToggle: settings.useRealtimeUpdates + ? null + : (newValue) { + settings.setAlwaysUseBatterySaveMode(newValue); + settings.save(); + }, + title: Opacity( + opacity: + settings.useRealtimeUpdates ? OFF_OPACITY : 1, + child: Text( + l10n + .settingsScreen_settings_alwaysUseBatterySaveMode_label, ), - leading: PlatformWidget( - material: (_, __) => const Icon(Icons.file_open), - cupertino: (_, __) => - const Icon(CupertinoIcons.doc), + ), + description: Opacity( + opacity: + settings.useRealtimeUpdates ? OFF_OPACITY : 1, + child: Text( + l10n + .settingsScreen_settings_alwaysUseBatterySaveMode_description, ), - trailing: const SettingsCaretIcon(), - onPressed: (_) async { - final taskService = context.read(); - final viewService = context.read(); - final settings = context.read(); - - final shouldSave = await showPlatformDialog( - context: context, - builder: (context) => PlatformAlertDialog( - title: Text(l10n - .settingsScreen_settings_importExport_exportFile), - content: Text(l10n - .settingsScreen_settings_importExport_exportFile_description), - actions: createCancellableDialogActions( - context, - [ - PlatformDialogAction( - material: (_, __) => - MaterialDialogActionData( - icon: const Icon(Icons.save), + ), + ), + SettingsTile.navigation( + title: Text( + l10n.settingsScreen_settings_importExport_exportFile, + ), + leading: PlatformWidget( + material: (_, __) => const Icon(Icons.file_open), + cupertino: (_, __) => const Icon(CupertinoIcons.doc), + ), + trailing: const SettingsCaretIcon(), + onPressed: (_) async { + final taskService = context.read(); + final viewService = context.read(); + final settings = context.read(); + + final shouldSave = await showPlatformDialog( + context: context, + builder: (context) => + PlatformAlertDialog( + title: Text(l10n + .settingsScreen_settings_importExport_exportFile), + content: Text(l10n + .settingsScreen_settings_importExport_exportFile_description), + actions: createCancellableDialogActions( + context, + [ + PlatformDialogAction( + material: (_, __) => + MaterialDialogActionData( + icon: const Icon(Icons.save), + ), + onPressed: () { + Navigator.pop(context, true); + }, + child: Text(l10n + .settingsScreen_settings_importExport_exportFile_save), ), - onPressed: () { - Navigator.pop(context, true); - }, - child: Text(l10n - .settingsScreen_settings_importExport_exportFile_save), - ), - ], + ], + ), ), - ), - ); + ); - if (shouldSave) { - final rawData = jsonEncode( - await exportToJSON( - taskService, viewService, settings), - ); + if (shouldSave) { + final rawData = jsonEncode( + await exportToJSON( + taskService, viewService, settings), + ); - final file = XFile( - (await createTempFile( - const Utf8Encoder().convert(rawData), - name: "export.locus.json", - )) - .path, - ); + final file = XFile( + (await createTempFile( + const Utf8Encoder().convert(rawData), + name: "export.locus.json", + )) + .path, + ); - await Share.shareXFiles( - [file], - text: "Locus view key", - subject: - l10n.shareLocation_actions_shareFile_text, - ); - } - }, - ), - if (Platform.isAndroid && isGMSFlavor) - SettingsTile.navigation( - title: Text(l10n - .settingsScreen_settings_importExport_transfer), - leading: PlatformWidget( - material: (_, __) => - const Icon(Icons.phonelink_setup_rounded), - cupertino: (_, __) => const Icon( - CupertinoIcons.device_phone_portrait), - ), - trailing: const SettingsCaretIcon(), - onPressed: (_) { - Navigator.push( - context, - NativePageRoute( - context: context, - builder: (context) => - const TransferSenderScreen(), - ), - ); - }, - ), + await Share.shareXFiles( + [file], + text: "Locus view key", + subject: + l10n.shareLocation_actions_shareFile_text, + ); + } + }, + ), + if (Platform.isAndroid && isGMSFlavor) SettingsTile.navigation( title: Text(l10n - .settingsScreen_settings_importExport_importLabel), + .settingsScreen_settings_importExport_transfer), leading: PlatformWidget( material: (_, __) => - const Icon(Icons.file_download), + const Icon(Icons.phonelink_setup_rounded), cupertino: (_, __) => - const Icon(CupertinoIcons.tray_arrow_down_fill), + const Icon( + CupertinoIcons.device_phone_portrait), ), trailing: const SettingsCaretIcon(), - onPressed: (_) async { - final shouldPopContext = - await showPlatformModalSheet( - context: context, - material: MaterialModalSheetData( - backgroundColor: Colors.transparent, - ), - builder: (context) => ImportSheet( - onImport: ( - final taskService, - final viewService, - final settings, - ) async { - await Future.wait([ - taskService.save(), - viewService.save(), - settings.save(), - ]); - - if (context.mounted) { - final shouldClose = - await showPlatformDialog( - context: context, - barrierDismissible: !Platform.isAndroid, - builder: (context) => PlatformAlertDialog( - title: Text(l10n - .settingsScreen_import_restart_title), - content: Text(l10n - .settingsScreen_import_restart_description), - actions: [ - PlatformDialogAction( - child: Text(l10n.closeApp), - onPressed: () => Navigator.pop( - context, Platform.isAndroid), - ), - ], - ), - ); - - if (!mounted) { - return; - } - - if (shouldClose != true) { - Navigator.pop(context); - return; - } - - exit(0); - } - }, - ), - ); - - if (shouldPopContext && mounted) { - Navigator.pop(context); - } - }, - ), - SettingsTile.navigation( - title: Text(l10n.checkLocation_title), - description: - Text(l10n.checkLocation_shortDescription), - trailing: const SettingsCaretIcon(), - leading: PlatformFlavorWidget( - material: (_, __) => - const Icon(Icons.edit_location_alt), - cupertino: (_, __) => - const Icon(CupertinoIcons.location_fill), - ), onPressed: (_) { Navigator.push( context, NativePageRoute( context: context, builder: (context) => - const CheckLocationScreen(), + const TransferSenderScreen(), ), ); }, + ), + SettingsTile.navigation( + title: Text(l10n + .settingsScreen_settings_importExport_importLabel), + leading: PlatformWidget( + material: (_, __) => const Icon(Icons.file_download), + cupertino: (_, __) => + const Icon(CupertinoIcons.tray_arrow_down_fill), + ), + trailing: const SettingsCaretIcon(), + onPressed: (_) async { + final shouldPopContext = await showPlatformModalSheet( + context: context, + material: MaterialModalSheetData( + backgroundColor: Colors.transparent, + ), + builder: (context) => + ImportSheet( + onImport: (final taskService, + final viewService, + final settings,) async { + await Future.wait([ + taskService.save(), + viewService.save(), + settings.save(), + ]); + + if (context.mounted) { + final shouldClose = await showPlatformDialog( + context: context, + barrierDismissible: !Platform.isAndroid, + builder: (context) => + PlatformAlertDialog( + title: Text(l10n + .settingsScreen_import_restart_title), + content: Text(l10n + .settingsScreen_import_restart_description), + actions: [ + PlatformDialogAction( + child: Text(l10n.closeApp), + onPressed: () => + Navigator.pop( + context, + Platform.isAndroid), + ), + ], + ), + ); + + if (!mounted) { + return; + } + + if (shouldClose != true) { + Navigator.pop(context); + return; + } + + exit(0); + } + }, + ), + ); + + if (shouldPopContext && mounted) { + Navigator.pop(context); + } + }, + ), + SettingsTile.navigation( + title: Text(l10n.checkLocation_title), + description: Text(l10n.checkLocation_shortDescription), + trailing: const SettingsCaretIcon(), + leading: PlatformFlavorWidget( + material: (_, __) => + const Icon(Icons.edit_location_alt), + cupertino: (_, __) => + const Icon(CupertinoIcons.location_fill), + ), + onPressed: (_) { + Navigator.push( + context, + NativePageRoute( + context: context, + builder: (context) => const CheckLocationScreen(), + ), + ); + }, + ) + ], + ), + if (kDebugMode) + SettingsSection( + title: const Text("Debug"), + tiles: [ + SettingsTile( + title: const Text("Reset App"), + onPressed: (_) async { + storage.deleteAll(); + + exit(0); + }, + ), + SettingsTile( + title: const Text("Run Background Location Task"), + onPressed: (_) async { + await runBackgroundTask(); + }, + ), + SettingsTile( + title: const Text( + "Run Background Location Task - Forced"), + onPressed: (_) async { + await runBackgroundTask(force: true); + }, ) ], ), - if (kDebugMode) - SettingsSection( - title: const Text("Debug"), - tiles: [ - SettingsTile( - title: const Text("Reset App"), - onPressed: (_) async { - storage.deleteAll(); - - exit(0); - }, - ) - ], - ), - ]), + ], + ), const SizedBox(height: MEDIUM_SPACE), Padding( padding: const EdgeInsets.all(MEDIUM_SPACE), @@ -667,7 +695,7 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.code), title: Text(l10n.support_options_develop), subtitle: - Text(l10n.support_options_develop_description), + Text(l10n.support_options_develop_description), onTap: () { launchUrl( Uri.parse(REPOSITORY_URL), @@ -679,7 +707,7 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.translate_rounded), title: Text(l10n.support_options_translate), subtitle: - Text(l10n.support_options_translate_description), + Text(l10n.support_options_translate_description), onTap: () { launchUrl( Uri.parse(TRANSLATION_HELP_URL), @@ -690,13 +718,13 @@ class _SettingsScreenState extends State { PlatformListTile( leading: PlatformWidget( material: (_, __) => - const Icon(Icons.attach_money_rounded), + const Icon(Icons.attach_money_rounded), cupertino: (_, __) => - const Icon(CupertinoIcons.money_euro), + const Icon(CupertinoIcons.money_euro), ), title: Text(l10n.support_options_donate), subtitle: - Text(l10n.support_options_donate_description), + Text(l10n.support_options_donate_description), onTap: () { launchUrl( Uri.parse(DONATION_URL), @@ -756,14 +784,14 @@ class _SettingsScreenState extends State { MentionTile( title: l10n.honorableMentions_values_session, description: - l10n.honorableMentions_values_session_description, + l10n.honorableMentions_values_session_description, iconName: "session.png", url: "https://getsession.org/", ), MentionTile( title: l10n.honorableMentions_values_odysee, description: - l10n.honorableMentions_values_odysee_description, + l10n.honorableMentions_values_odysee_description, iconName: "odysee.png", url: "https://odysee.com/", ), diff --git a/lib/screens/SharesOverviewScreen.dart b/lib/screens/SharesOverviewScreen.dart index 9cdb9484..fa4fb5ac 100644 --- a/lib/screens/SharesOverviewScreen.dart +++ b/lib/screens/SharesOverviewScreen.dart @@ -6,12 +6,12 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/screens/shares_overview_screen_widgets/screens/EmptyScreen.dart'; import 'package:locus/screens/shares_overview_screen_widgets/screens/TasksOverviewScreen.dart'; -import 'package:locus/services/task_service.dart'; -import 'package:locus/services/view_service.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'; import 'package:locus/utils/theme.dart'; import 'package:provider/provider.dart'; -import '../services/settings_service.dart'; import 'CreateTaskScreen.dart'; import 'shares_overview_screen_widgets/values.dart'; @@ -26,22 +26,9 @@ class SharesOverviewScreen extends StatefulWidget { class _SharesOverviewScreenState extends State { final listViewKey = GlobalKey(); - final PageController _tabController = PageController(); late final TaskService taskService; int activeTab = 0; - void _changeTab(final int newTab) { - setState(() { - activeTab = newTab; - }); - - _tabController.animateToPage( - newTab, - duration: getTransitionDuration(context), - curve: Curves.easeInOut, - ); - } - PlatformAppBar? getAppBar() { final l10n = AppLocalizations.of(context); @@ -118,11 +105,11 @@ class _SharesOverviewScreenState extends State { // Settings bottomNavBar via cupertino data class does not work bottomNavBar: isCupertino(context) ? PlatformNavBar( - material: (_, __) => MaterialNavBarData( - backgroundColor: Theme.of(context).dialogBackgroundColor, - elevation: 0, - padding: const EdgeInsets.all(0)), - itemChanged: _changeTab, + itemChanged: (index) { + setState(() { + activeTab = index; + }); + }, currentIndex: activeTab, items: [ BottomNavigationBarItem( @@ -136,19 +123,15 @@ class _SharesOverviewScreenState extends State { ], ) : null, - body: PageView( - controller: _tabController, - physics: const NeverScrollableScrollPhysics(), - children: [ - const TasksOverviewScreen(), - if (isCupertino(context)) - CreateTaskScreen( + body: activeTab == 0 + ? const TasksOverviewScreen() + : CreateTaskScreen( onCreated: () { - _changeTab(0); + setState(() { + activeTab = 0; + }); }, ), - ], - ), ); } } diff --git a/lib/screens/ShortcutScreen.dart b/lib/screens/ShortcutScreen.dart index a64cc562..3fb8ee3a 100644 --- a/lib/screens/ShortcutScreen.dart +++ b/lib/screens/ShortcutScreen.dart @@ -4,7 +4,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/init_quick_actions.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/services/timers_service.dart'; import 'package:locus/utils/location/index.dart'; import 'package:locus/utils/platform.dart'; @@ -14,7 +14,7 @@ import 'package:provider/provider.dart'; import '../models/log.dart'; import '../services/location_point_service.dart'; import '../services/log_service.dart'; -import '../services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import '../utils/theme.dart'; class ShortcutScreen extends StatefulWidget { @@ -76,7 +76,7 @@ class _ShortcutScreenState extends State { await task.startSchedule(startNowIfNextRunIsUnknown: true); - final locationPoint = await task.publishCurrentPosition(); + final locationPoint = await task.publisher.publishCurrentPosition(); await logService.addLog( Log.updateLocation( @@ -102,7 +102,7 @@ class _ShortcutScreenState extends State { ); await Future.wait( tasks.map( - (task) => task.publishLocation( + (task) => task.publisher.publishLocation( locationPoint.copyWithDifferentId(), ), ), diff --git a/lib/screens/TaskDetailScreen.dart b/lib/screens/TaskDetailScreen.dart index c8b5e658..4cce6104 100644 --- a/lib/screens/TaskDetailScreen.dart +++ b/lib/screens/TaskDetailScreen.dart @@ -1,27 +1,15 @@ -import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/screens/task_detail_screen_widgets/Details.dart'; -import 'package:locus/services/location_fetch_controller.dart'; -import 'package:locus/services/task_service.dart'; -import 'package:locus/utils/bunny.dart'; -import 'package:locus/widgets/EmptyLocationsThresholdScreen.dart'; -import 'package:locus/widgets/LocationFetchError.dart'; -import 'package:locus/widgets/LocationStillFetchingBanner.dart'; -import 'package:locus/widgets/LocationsLoadingScreen.dart'; -import 'package:locus/widgets/LocationsMap.dart'; -import 'package:map_launcher/map_launcher.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:provider/provider.dart'; import '../constants/spacing.dart'; -import '../services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import '../utils/helper_sheet.dart'; import '../utils/theme.dart'; -import '../widgets/LocationFetchEmpty.dart'; -import '../widgets/OpenInMaps.dart'; -import '../widgets/PlatformPopup.dart'; const DEBOUNCE_DURATION = Duration(seconds: 2); const DEFAULT_LOCATION_LIMIT = 50; @@ -39,52 +27,10 @@ class TaskDetailScreen extends StatefulWidget { } class _TaskDetailScreenState extends State { - final PageController _pageController = PageController(); - late final LocationFetcher _locationFetcher; - bool _isError = false; - bool _isShowingDetails = false; - @override void initState() { super.initState(); - emptyLocationsCount++; - - _locationFetcher = widget.task.createLocationFetcher( - onLocationFetched: (final location) { - emptyLocationsCount = 0; - // Only update partially to avoid lag - EasyThrottle.throttle( - "${widget.task.id}:location-fetch", - DEBOUNCE_DURATION, - () { - if (!mounted) { - return; - } - setState(() {}); - }, - ); - }, - ); - - _locationFetcher.fetchMore( - onEnd: () { - setState(() {}); - }, - ); - - _pageController.addListener(() { - if (_pageController.page == 0) { - setState(() { - _isShowingDetails = false; - }); - } else { - setState(() { - _isShowingDetails = true; - }); - } - }); - WidgetsBinding.instance.addPostFrameCallback((_) async { final settings = context.read(); @@ -144,23 +90,11 @@ class _TaskDetailScreenState extends State { ); } - @override - void dispose() { - _pageController.dispose(); - _locationFetcher.dispose(); - - super.dispose(); - } - @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - return PlatformScaffold( appBar: PlatformAppBar( - title: Text( - _isShowingDetails ? l10n.taskDetails_title : widget.task.name, - ), + title: Text(widget.task.name), material: (_, __) => MaterialAppBarData( centerTitle: true, ), @@ -168,19 +102,6 @@ class _TaskDetailScreenState extends State { backgroundColor: getCupertinoAppBarColorForMapScreen(context), ), trailingActions: [ - if (_locationFetcher.controller.locations.isNotEmpty && - !_isShowingDetails) - PlatformIconButton( - cupertino: (_, __) => CupertinoIconButtonData( - padding: EdgeInsets.zero, - ), - icon: const Icon(Icons.my_location_rounded), - onPressed: () { - // No need to check for location permission, as the user must enable it to create locations - // in the first place - _locationFetcher.controller.goToUserLocation(); - }, - ), PlatformIconButton( cupertino: (_, __) => CupertinoIconButtonData( padding: EdgeInsets.zero, @@ -188,146 +109,15 @@ class _TaskDetailScreenState extends State { icon: Icon(context.platformIcons.help), onPressed: showHelp, ), - Padding( - padding: isMaterial(context) - ? const EdgeInsets.all(SMALL_SPACE) - : EdgeInsets.zero, - child: PlatformPopup( - type: PlatformPopupType.tap, - cupertinoButtonPadding: EdgeInsets.zero, - items: [ - PlatformPopupMenuItem( - label: PlatformListTile( - leading: Icon(context.platformIcons.location), - trailing: const SizedBox.shrink(), - title: Text(l10n.viewDetails_actions_openLatestLocation), - ), - onPressed: () async { - await showPlatformModalSheet( - context: context, - material: MaterialModalSheetData( - backgroundColor: Colors.transparent, - ), - builder: (context) => OpenInMaps( - destination: Coords( - _locationFetcher.controller.locations.last.latitude, - _locationFetcher.controller.locations.last.longitude, - ), - ), - ); - }, - ), - // If the fetched locations are less than the limit, - // there are definitely no more locations to fetch - if (_locationFetcher.canFetchMore) - PlatformPopupMenuItem( - label: PlatformListTile( - leading: Icon(context.platformIcons.refresh), - trailing: const SizedBox.shrink(), - title: Text(l10n.locationFetcher_actions_fetchMore), - ), - onPressed: () { - _locationFetcher.fetchMore(onEnd: () { - setState(() {}); - }); - }, - ), - ], - ), - ), ], ), - body: _isError - ? const LocationFetchError() - : PageView( - physics: const NeverScrollableScrollPhysics(), - scrollDirection: Axis.vertical, - controller: _pageController, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 9, - child: (() { - if (_locationFetcher.controller.locations.isNotEmpty) { - return Stack( - children: [ - LocationsMap( - controller: _locationFetcher.controller, - ), - if (_locationFetcher.isLoading) - const LocationStillFetchingBanner(), - ], - ); - } - - if (_locationFetcher.isLoading) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(MEDIUM_SPACE), - child: LocationsLoadingScreen( - locations: - _locationFetcher.controller.locations, - onTimeout: () { - setState(() { - _isError = true; - }); - }, - ), - ), - ); - } - - if (emptyLocationsCount > EMPTY_LOCATION_THRESHOLD) { - return const EmptyLocationsThresholdScreen(); - } - - return const LocationFetchEmpty(); - })(), - ), - Expanded( - flex: 1, - child: PlatformTextButton( - material: (_, __) => MaterialTextButtonData( - style: ButtonStyle( - // Not rounded, but square - shape: MaterialStateProperty.all( - const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - ), - ), - ), - ), - child: Text(l10n.taskDetails_goToDetails), - onPressed: () { - _pageController.animateToPage( - 1, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); - }, - ), - ), - ], - ), - SafeArea( - child: SingleChildScrollView( - child: Details( - locations: _locationFetcher.controller.locations, - task: widget.task, - onGoBack: () { - _pageController.animateToPage( - 0, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); - }, - ), - ), - ), - ], - ), + body: SafeArea( + child: SingleChildScrollView( + child: Details( + task: widget.task, + ), + ), + ), ); } } diff --git a/lib/screens/ViewDetailScreen.dart b/lib/screens/ViewDetailScreen.dart deleted file mode 100644 index 2aeabde4..00000000 --- a/lib/screens/ViewDetailScreen.dart +++ /dev/null @@ -1,482 +0,0 @@ -import 'dart:async'; - -import 'package:easy_debounce/easy_throttle.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' - hide PlatformListTile; -import 'package:geolocator/geolocator.dart'; -import 'package:locus/screens/view_alarm_screen_widgets/ViewAlarmScreen.dart'; -import 'package:locus/screens/view_details_screen_widgets/ViewLocationPointsScreen.dart'; -import 'package:locus/services/location_alarm_service.dart'; -import 'package:locus/services/view_service.dart'; -import 'package:locus/utils/PageRoute.dart'; -import 'package:locus/utils/bunny.dart'; -import 'package:locus/utils/permissions/has-granted.dart'; -import 'package:locus/utils/permissions/request.dart'; -import 'package:locus/widgets/EmptyLocationsThresholdScreen.dart'; -import 'package:locus/widgets/FillUpPaint.dart'; -import 'package:locus/widgets/LocationFetchEmpty.dart'; -import 'package:locus/widgets/LocationsMap.dart'; -import 'package:locus/widgets/OpenInMaps.dart'; -import 'package:locus/widgets/PlatformFlavorWidget.dart'; -import 'package:locus/widgets/PlatformPopup.dart'; -import 'package:map_launcher/map_launcher.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; - -import '../constants/spacing.dart'; -import '../services/location_fetch_controller.dart'; -import '../services/location_point_service.dart'; -import '../utils/theme.dart'; -import '../widgets/LocationFetchError.dart'; -import '../widgets/LocationStillFetchingBanner.dart'; -import '../widgets/LocationsLoadingScreen.dart'; -import '../widgets/PlatformListTile.dart'; - -const DEBOUNCE_DURATION = Duration(seconds: 2); - -class LineSliderTickMarkShape extends SliderTickMarkShape { - final double tickWidth; - - const LineSliderTickMarkShape({ - this.tickWidth = 1.0, - }) : super(); - - @override - Size getPreferredSize( - {required SliderThemeData sliderTheme, required bool isEnabled}) { - // We don't need this - return Size.zero; - } - - @override - void paint( - PaintingContext context, - Offset center, { - required RenderBox parentBox, - required SliderThemeData sliderTheme, - required Animation enableAnimation, - required Offset thumbCenter, - required bool isEnabled, - required TextDirection textDirection, - }) { - // This block is just copied from `slider_theme` - final bool isTickMarkRightOfThumb = center.dx > thumbCenter.dx; - final begin = isTickMarkRightOfThumb - ? sliderTheme.disabledInactiveTickMarkColor - : sliderTheme.disabledActiveTickMarkColor; - final end = isTickMarkRightOfThumb - ? sliderTheme.inactiveTickMarkColor - : sliderTheme.activeTickMarkColor; - final Paint paint = Paint() - ..color = ColorTween(begin: begin, end: end).evaluate(enableAnimation)!; - - final trackHeight = sliderTheme.trackHeight!; - - final rect = Rect.fromCenter( - center: center, - width: tickWidth, - height: trackHeight, - ); - - context.canvas.drawRect(rect, paint); - } -} - -class ViewDetailScreen extends StatefulWidget { - final TaskView view; - - const ViewDetailScreen({ - required this.view, - Key? key, - }) : super(key: key); - - @override - State createState() => _ViewDetailScreenState(); -} - -class _ViewDetailScreenState extends State { - // `_controller` is used to control the actively shown locations on the map - final LocationsMapController _controller = LocationsMapController(); - - // `_locationFetcher.controller` is used to control ALL locations - late final LocationFetcher _locationFetcher; - StreamSubscription? _positionUpdateStream; - - bool _isError = false; - - bool showAlarms = true; - - double? distanceToLatestLocation; - - @override - void initState() { - super.initState(); - - emptyLocationsCount++; - - _locationFetcher = widget.view.createLocationFetcher( - onLocationFetched: (final location) { - emptyLocationsCount = 0; - - _controller.add(location); - // Only update partially to avoid lag - EasyThrottle.throttle( - "${widget.view.id}:location-fetch", - DEBOUNCE_DURATION, - () { - if (!mounted) { - return; - } - setState(() {}); - }, - ); - }, - ); - - _locationFetcher.fetchMore( - onEnd: () { - _updateDistanceToLocation(); - setState(() {}); - }, - ); - - // Update UI when for example alarms are added or removed - widget.view.addListener(updateView); - } - - void _updateDistanceToLocation() async { - if (_locationFetcher.controller.locations.isEmpty || - _positionUpdateStream != null || - !(await hasGrantedLocationPermission())) { - return; - } - - _positionUpdateStream = Geolocator.getPositionStream().listen((position) { - if (!mounted) { - return; - } - - setState(() { - distanceToLatestLocation = Geolocator.distanceBetween( - position.latitude, - position.longitude, - _locationFetcher.controller.locations.last.latitude, - _locationFetcher.controller.locations.last.longitude, - ); - }); - }); - } - - void updateView() { - setState(() {}); - } - - @override - void dispose() { - _locationFetcher.dispose(); - _controller.dispose(); - _positionUpdateStream?.cancel(); - widget.view.removeListener(updateView); - - super.dispose(); - } - - VoidCallback handleTapOnDate(final Iterable locations) { - return () { - _controller.clear(); - - if (locations.isNotEmpty) { - _controller.addAll(locations); - _controller.goTo(locations.last); - } - - setState(() {}); - }; - } - - Widget buildDateSelectButton( - final List locations, - final int hour, - final int maxLocations, - ) { - final shades = getPrimaryColorShades(context); - - return FillUpPaint( - color: shades[0]!, - fillPercentage: - maxLocations == 0 ? 0 : (locations.length.toDouble() / maxLocations), - size: Size( - MediaQuery.of(context).size.width / 24, - MediaQuery.of(context).size.height * (1 / 12), - ), - ); - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final locationsPerHour = _locationFetcher.controller.getLocationsPerHour(); - final maxLocations = locationsPerHour.values.isEmpty - ? 0 - : locationsPerHour.values.fold( - 0, - (value, element) => - value > element.length ? value : element.length); - - return PlatformScaffold( - appBar: PlatformAppBar( - title: Text(l10n.viewDetails_title), - trailingActions: [ - if (_locationFetcher.controller.locations.isNotEmpty) - PlatformIconButton( - cupertino: (_, __) => CupertinoIconButtonData( - padding: EdgeInsets.zero, - ), - icon: const Icon(Icons.my_location_rounded), - onPressed: () async { - final hasGrantedLocation = - await requestBasicLocationPermission(); - - if (hasGrantedLocation) { - _controller.goToUserLocation(); - } - }, - ), - if (widget.view.alarms.isNotEmpty && _controller.locations.isNotEmpty) - Tooltip( - message: showAlarms - ? l10n.viewDetails_actions_showAlarms_hide - : l10n.viewDetails_actions_showAlarms_show, - child: PlatformTextButton( - cupertino: (_, __) => CupertinoTextButtonData( - padding: EdgeInsets.zero, - ), - onPressed: () { - setState(() { - showAlarms = !showAlarms; - }); - }, - child: PlatformFlavorWidget( - material: (_, __) => showAlarms - ? const Icon(Icons.alarm_rounded) - : const Icon(Icons.alarm_off_rounded), - cupertino: (_, __) => showAlarms - ? const Icon(CupertinoIcons.alarm) - : const Icon(Icons.alarm_off_rounded), - ), - ), - ), - Padding( - padding: isMaterial(context) - ? const EdgeInsets.all(SMALL_SPACE) - : EdgeInsets.zero, - child: PlatformPopup( - cupertinoButtonPadding: EdgeInsets.zero, - type: PlatformPopupType.tap, - items: [ - PlatformPopupMenuItem( - label: PlatformListTile( - leading: PlatformFlavorWidget( - cupertino: (_, __) => const Icon(CupertinoIcons.alarm), - material: (_, __) => const Icon(Icons.alarm_rounded), - ), - title: Text(l10n.location_manageAlarms_title), - trailing: const SizedBox.shrink(), - ), - onPressed: () { - if (isCupertino(context)) { - Navigator.of(context).push( - MaterialWithModalsPageRoute( - builder: (_) => ViewAlarmScreen(view: widget.view), - ), - ); - } else { - Navigator.of(context).push( - NativePageRoute( - context: context, - builder: (_) => ViewAlarmScreen(view: widget.view), - ), - ); - } - }), - if (_locationFetcher.controller.locations.isNotEmpty) - PlatformPopupMenuItem( - label: PlatformListTile( - leading: Icon(context.platformIcons.location), - trailing: const SizedBox.shrink(), - title: Text(l10n.viewDetails_actions_openLatestLocation), - ), - onPressed: () => showPlatformModalSheet( - context: context, - material: MaterialModalSheetData(), - builder: (context) => OpenInMaps( - destination: Coords( - _locationFetcher.controller.locations.last.latitude, - _locationFetcher.controller.locations.last.longitude, - ), - ), - ), - ), - if (_locationFetcher.controller.locations.isNotEmpty) - PlatformPopupMenuItem( - label: PlatformListTile( - leading: PlatformFlavorWidget( - material: (_, __) => const Icon(Icons.list_rounded), - cupertino: (_, __) => - const Icon(CupertinoIcons.list_bullet), - ), - trailing: const SizedBox.shrink(), - title: Text(l10n.viewDetails_actions_showLocationList), - ), - onPressed: () { - Navigator.push( - context, - NativePageRoute( - context: context, - builder: (context) => ViewLocationPointsScreen( - locationFetcher: _locationFetcher, - ), - ), - ); - }, - ), - ], - ), - ), - ], - material: (_, __) => MaterialAppBarData( - centerTitle: true, - ), - cupertino: (_, __) => CupertinoNavigationBarData( - backgroundColor: getCupertinoAppBarColorForMapScreen(context), - ), - ), - body: (() { - if (_isError) { - return const LocationFetchError(); - } - - if (_locationFetcher.controller.locations.isNotEmpty) { - return PageView( - physics: const NeverScrollableScrollPhysics(), - children: [ - Column( - children: [ - Expanded( - flex: 11, - child: Stack( - children: [ - LocationsMap( - controller: _controller, - showCircles: showAlarms, - circles: List.from( - List.from( - widget.view.alarms) - .map( - (final alarm) => LocationsMapCircle( - id: alarm.id, - center: alarm.center, - radius: alarm.radius, - color: Colors.red.withOpacity(.3), - strokeColor: Colors.red, - ), - ), - )), - if (_locationFetcher.isLoading) - const LocationStillFetchingBanner(), - if (distanceToLatestLocation != null) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - l10n.viewDetails_distanceToLatestLocation_label( - distanceToLatestLocation!.round(), - ), - style: TextStyle( - color: getIsDarkMode(context) - ? Colors.white - : isCupertino(context) - ? CupertinoColors.secondaryLabel - : Colors.black87, - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ) - ], - ), - ), - Expanded( - flex: 1, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: - List.generate(24, (index) => 23 - index).map((hour) { - final date = - DateTime.now().subtract(Duration(hours: hour)); - final normalizedDate = - LocationsMapController.normalizeDateTime(date); - final locations = - locationsPerHour[normalizedDate] ?? []; - final child = buildDateSelectButton( - locations, - hour, - maxLocations, - ); - - return PlatformWidget( - material: (_, __) => InkWell( - onTap: handleTapOnDate(locations), - child: child, - ), - cupertino: (_, __) => GestureDetector( - onTap: handleTapOnDate(locations), - child: child, - ), - ); - }).toList(), - ), - ), - ], - ), - ], - ); - } - - if (_locationFetcher.isLoading) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(MEDIUM_SPACE), - child: LocationsLoadingScreen( - locations: _locationFetcher.controller.locations, - onTimeout: () { - setState(() { - _isError = true; - }); - }, - ), - ), - ); - } - - if (emptyLocationsCount > EMPTY_LOCATION_THRESHOLD) { - return const EmptyLocationsThresholdScreen(); - } - - return const LocationFetchEmpty(); - })(), - ); - } -} diff --git a/lib/screens/ViewDetailsScreen.dart b/lib/screens/ViewDetailsScreen.dart new file mode 100644 index 00000000..33132316 --- /dev/null +++ b/lib/screens/ViewDetailsScreen.dart @@ -0,0 +1,95 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' + hide PlatformListTile; +import 'package:locus/screens/view_alarm_screen_widgets/ViewAlarmScreen.dart'; +import 'package:locus/screens/view_details_screen_widgets/LocationPointsList.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/PageRoute.dart'; +import 'package:locus/widgets/Paper.dart'; +import 'package:locus/widgets/PlatformFlavorWidget.dart'; +import 'package:locus/widgets/PlatformPopup.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; + +import '../constants/spacing.dart'; +import '../utils/theme.dart'; +import '../widgets/PlatformListTile.dart'; + +class ViewDetailsScreen extends StatelessWidget { + final TaskView view; + + const ViewDetailsScreen({ + super.key, + required this.view, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return PlatformScaffold( + appBar: PlatformAppBar( + title: Text(l10n.viewDetails_title), + trailingActions: [ + Padding( + padding: isMaterial(context) + ? const EdgeInsets.all(SMALL_SPACE) + : EdgeInsets.zero, + child: PlatformPopup( + cupertinoButtonPadding: EdgeInsets.zero, + type: PlatformPopupType.tap, + items: [ + PlatformPopupMenuItem( + label: PlatformListTile( + leading: PlatformFlavorWidget( + cupertino: (_, __) => const Icon(CupertinoIcons.alarm), + material: (_, __) => const Icon(Icons.alarm_rounded), + ), + title: Text(l10n.location_manageAlarms_title), + trailing: const SizedBox.shrink(), + ), + onPressed: () { + if (isCupertino(context)) { + Navigator.of(context).push( + MaterialWithModalsPageRoute( + builder: (_) => ViewAlarmScreen(view: view), + ), + ); + } else { + Navigator.of(context).push( + NativePageRoute( + context: context, + builder: (_) => ViewAlarmScreen(view: view), + ), + ); + } + }), + ], + ), + ), + ], + material: (_, __) => MaterialAppBarData( + centerTitle: true, + ), + cupertino: (_, __) => CupertinoNavigationBarData( + backgroundColor: getCupertinoAppBarColorForMapScreen(context), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(MEDIUM_SPACE), + child: Column( + children: [ + Paper( + child: LocationPointsList( + view: view, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/WelcomeScreen.dart b/lib/screens/WelcomeScreen.dart index 1c89885b..2c575851 100644 --- a/lib/screens/WelcomeScreen.dart +++ b/lib/screens/WelcomeScreen.dart @@ -9,7 +9,7 @@ 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.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:locus/utils/PageRoute.dart'; import 'package:lottie/lottie.dart'; import 'package:provider/provider.dart'; diff --git a/lib/screens/create_task_screen_widgets/ExampleTasksRoulette.dart b/lib/screens/create_task_screen_widgets/ExampleTasksRoulette.dart index 732ba90d..2212e689 100644 --- a/lib/screens/create_task_screen_widgets/ExampleTasksRoulette.dart +++ b/lib/screens/create_task_screen_widgets/ExampleTasksRoulette.dart @@ -3,11 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/services/timers_service.dart'; import 'package:locus/utils/theme.dart'; -import '../../services/task_service.dart'; - List getExamples(final BuildContext context) { final l10n = AppLocalizations.of(context); diff --git a/lib/screens/import_task_sheet_widgets/NameForm.dart b/lib/screens/import_task_sheet_widgets/NameForm.dart index 3043a557..bdc2bf0a 100644 --- a/lib/screens/import_task_sheet_widgets/NameForm.dart +++ b/lib/screens/import_task_sheet_widgets/NameForm.dart @@ -8,7 +8,7 @@ import 'package:locus/utils/color.dart'; import 'package:provider/provider.dart'; import '../../constants/spacing.dart'; -import '../../services/view_service.dart'; +import '../../services/view_service/index.dart'; import '../../utils/theme.dart'; import '../../widgets/ModalSheetContent.dart'; @@ -72,25 +72,28 @@ class _NameFormState extends State { } if (viewService.views.any( - (element) => element.name.toLowerCase() == lowerCasedName)) { + (element) => + element.name.toLowerCase() == lowerCasedName)) { return l10n .sharesOverviewScreen_importTask_action_name_errors_sameNameAlreadyExists; } return null; }, - material: (_, __) => MaterialTextFormFieldData( - decoration: InputDecoration( - labelText: + material: (_, __) => + MaterialTextFormFieldData( + decoration: InputDecoration( + labelText: l10n.sharesOverviewScreen_importTask_action_name_label, - icon: const Icon(Icons.text_fields_rounded), - ), - ), - cupertino: (_, __) => CupertinoTextFormFieldData( - placeholder: + icon: const Icon(Icons.text_fields_rounded), + ), + ), + cupertino: (_, __) => + CupertinoTextFormFieldData( + placeholder: l10n.sharesOverviewScreen_importTask_action_name_label, - prefix: const Icon(CupertinoIcons.textformat), - ), + prefix: const Icon(CupertinoIcons.textformat), + ), ), const SizedBox(height: MEDIUM_SPACE), Text( @@ -116,7 +119,7 @@ class _NameFormState extends State { for (final color in Colors.primaries) Padding( padding: - const EdgeInsets.symmetric(horizontal: SMALL_SPACE), + const EdgeInsets.symmetric(horizontal: SMALL_SPACE), child: GestureDetector( onTap: () { setState(() { diff --git a/lib/screens/import_task_sheet_widgets/ReceiveViewByBluetooth.dart b/lib/screens/import_task_sheet_widgets/ReceiveViewByBluetooth.dart index ac64c37e..30fd1861 100644 --- a/lib/screens/import_task_sheet_widgets/ReceiveViewByBluetooth.dart +++ b/lib/screens/import_task_sheet_widgets/ReceiveViewByBluetooth.dart @@ -14,7 +14,7 @@ import 'package:locus/widgets/Paper.dart'; import 'package:lottie/lottie.dart'; import 'package:nearby_connections/nearby_connections.dart'; -import '../../services/view_service.dart'; +import '../../services/view_service/index.dart'; import '../../utils/import_export_handler.dart'; class ReceiveViewByBluetooth extends StatefulWidget { @@ -79,28 +79,32 @@ class _ReceiveViewByBluetoothState extends State final acceptConnection = await showPlatformDialog( context: context, barrierDismissible: false, - builder: (context) => PlatformAlertDialog( - title: Text(l10n.importTask_bluetooth_receive_request_title), - content: + builder: (context) => + PlatformAlertDialog( + title: Text(l10n.importTask_bluetooth_receive_request_title), + content: Text(l10n.importTask_bluetooth_receive_request_description), - actions: [ - PlatformDialogAction( - onPressed: () => Navigator.of(context).pop(false), - material: (_, __) => MaterialDialogActionData( - icon: const Icon(Icons.close), - ), - child: + actions: [ + PlatformDialogAction( + onPressed: () => Navigator.of(context).pop(false), + material: (_, __) => + MaterialDialogActionData( + icon: const Icon(Icons.close), + ), + child: Text(l10n.importTask_bluetooth_receive_request_decline), + ), + PlatformDialogAction( + onPressed: () => Navigator.of(context).pop(true), + material: (_, __) => + MaterialDialogActionData( + icon: const Icon(Icons.check), + ), + child: Text( + l10n.importTask_bluetooth_receive_request_accept), + ), + ], ), - PlatformDialogAction( - onPressed: () => Navigator.of(context).pop(true), - material: (_, __) => MaterialDialogActionData( - icon: const Icon(Icons.check), - ), - child: Text(l10n.importTask_bluetooth_receive_request_accept), - ), - ], - ), ); if (acceptConnection) { @@ -123,8 +127,7 @@ class _ReceiveViewByBluetoothState extends State } else { Nearby().rejectConnection(id); } - } catch (_) { - } finally { + } catch (_) {} finally { setState(() { isConfirmingRequest = false; }); diff --git a/lib/screens/import_task_sheet_widgets/ViewImportOverview.dart b/lib/screens/import_task_sheet_widgets/ViewImportOverview.dart index 903429c1..108f8bf1 100644 --- a/lib/screens/import_task_sheet_widgets/ViewImportOverview.dart +++ b/lib/screens/import_task_sheet_widgets/ViewImportOverview.dart @@ -2,10 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' hide PlatformListTile; -import 'package:locus/services/view_service.dart'; +import 'package:locus/screens/locations_overview_screen_widgets/LocationFetchers.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:provider/provider.dart'; import '../../constants/spacing.dart'; -import '../../services/location_point_service.dart'; import '../../utils/theme.dart'; import '../../widgets/LocationsMap.dart'; import '../../widgets/PlatformListTile.dart'; @@ -25,9 +26,7 @@ class ViewImportOverview extends StatefulWidget { } class _ViewImportOverviewState extends State { - void Function()? _unsubscribeGetLocations; final LocationsMapController _controller = LocationsMapController(); - bool _isLoading = true; final bool _isError = false; double timeOffset = 0; @@ -36,40 +35,21 @@ class _ViewImportOverviewState extends State { void initState() { super.initState(); - addListener(); + final fetchers = context.read(); + final lastLocation = fetchers.getLocations(widget.view).lastOrNull; + + if (lastLocation != null) { + _controller.add(lastLocation); + } } @override void dispose() { - _unsubscribeGetLocations?.call(); _controller.dispose(); super.dispose(); } - addListener() async { - _unsubscribeGetLocations = widget.view.getLocations( - limit: 1, - onLocationFetched: (final LocationPointService location) { - if (!mounted) { - return; - } - - _controller.add(location); - setState(() {}); - }, - onEnd: () { - if (!mounted) { - return; - } - - setState(() { - _isLoading = false; - }); - }, - ); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -99,14 +79,7 @@ class _ViewImportOverviewState extends State { ), ], ), - if (_isLoading) - const Padding( - padding: EdgeInsets.all(MEDIUM_SPACE), - child: Center( - child: CircularProgressIndicator(), - ), - ) - else if (_isError) + if (_isError) Text( l10n.locationsLoadingError, style: TextStyle( diff --git a/lib/screens/locations_overview_screen_widgets/ActiveSharesSheet.dart b/lib/screens/locations_overview_screen_widgets/ActiveSharesSheet.dart index 395b148f..b43590ed 100644 --- a/lib/screens/locations_overview_screen_widgets/ActiveSharesSheet.dart +++ b/lib/screens/locations_overview_screen_widgets/ActiveSharesSheet.dart @@ -7,6 +7,7 @@ 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/services/location_point_service.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/utils/location/index.dart'; import 'package:locus/widgets/PlatformFlavorWidget.dart'; import 'package:lottie/lottie.dart'; @@ -15,7 +16,6 @@ import 'package:visibility_detector/visibility_detector.dart'; import './TaskTile.dart'; import '../../constants/spacing.dart'; -import '../../services/task_service.dart'; import '../../utils/theme.dart'; import '../../widgets/ModalSheet.dart'; @@ -160,13 +160,12 @@ class _ActiveSharesSheetState extends State Iterable get quickShareTasks { final taskService = context.read(); - return taskService.tasks - .where((task) => task.deleteAfterRun && task.timers.length <= 1); + return taskService.tasks.where((task) => task.isQuickShare); } Future getAreSomeTasksRunning() async { final tasksRunning = - await Future.wait(quickShareTasks.map((task) => task.isRunning())); + await Future.wait(quickShareTasks.map((task) => task.isRunning())); return tasksRunning.any((isRunning) => isRunning); } @@ -188,10 +187,9 @@ class _ActiveSharesSheetState extends State await Future.wait( quickShareTasks.map( - (task) => - task.publishLocation( - locationData.copyWithDifferentId(), - ), + (task) => task.publisher.publishLocation( + locationData.copyWithDifferentId(), + ), ), ); @@ -204,8 +202,7 @@ class _ActiveSharesSheetState extends State FlutterLogs.logError( LOG_TAG, "ActiveSharesSheet", - "Error while updating location for ${quickShareTasks - .length} tasks: $error", + "Error while updating location for ${quickShareTasks.length} tasks: $error", ); } finally { setState(() { @@ -263,15 +260,9 @@ class _ActiveSharesSheetState extends State final shades = getPrimaryColorShades(context); return SizedBox( - height: MediaQuery - .of(context) - .size - .height - + height: MediaQuery.of(context).size.height - kToolbarHeight - - MediaQuery - .of(context) - .viewPadding - .top, + MediaQuery.of(context).viewPadding.top, child: Column( key: wrapperKey, mainAxisSize: MainAxisSize.max, @@ -323,10 +314,9 @@ class _ActiveSharesSheetState extends State ], ), PlatformElevatedButton( - material: (_, __) => - MaterialElevatedButtonData( - icon: const Icon(Icons.share_location_rounded), - ), + material: (_, __) => MaterialElevatedButtonData( + icon: const Icon(Icons.share_location_rounded), + ), onPressed: () { sheetController.animateTo( MIN_SIZE, @@ -371,13 +361,11 @@ class _ActiveSharesSheetState extends State if (someTasksRunning) { return [ PlatformFlavorWidget( - material: (_, __) => - const Icon( + material: (_, __) => const Icon( Icons.stop_circle_rounded, size: 42, ), - cupertino: (_, __) => - const Icon( + cupertino: (_, __) => const Icon( CupertinoIcons.stop_circle_fill, size: 42, ), @@ -392,13 +380,11 @@ class _ActiveSharesSheetState extends State return [ PlatformFlavorWidget( - material: (_, __) => - const Icon( + material: (_, __) => const Icon( Icons.play_circle_rounded, size: 42, ), - cupertino: (_, __) => - const Icon( + cupertino: (_, __) => const Icon( CupertinoIcons.play_circle_fill, size: 42, ), @@ -436,13 +422,11 @@ class _ActiveSharesSheetState extends State if (!allTasksRunning) { return PlatformFlavorWidget( - material: (_, __) => - const Icon( + material: (_, __) => const Icon( Icons.location_disabled_rounded, size: 42, ), - cupertino: (_, __) => - const Icon( + cupertino: (_, __) => const Icon( CupertinoIcons.location_slash_fill, size: 42, ), @@ -476,7 +460,7 @@ class _ActiveSharesSheetState extends State child: SizedBox.square(), ), Expanded( - flex: 8, + flex: 6, child: Center( child: Text( l10n.locationsOverview_activeShares_amount( @@ -554,64 +538,58 @@ class _ActiveSharesSheetState extends State return Opacity( opacity: isInitializing ? 0 : 1, child: PlatformWidget( - material: (context, _) => - AnimatedBuilder( - animation: offsetProgress, - builder: (context, child) => - Transform.translate( - offset: Offset(-_xOffset * (1 - offsetProgress.value), 0), - child: child, - ), - child: DraggableScrollableSheet( - snap: true, - snapSizes: const [MIN_SIZE, 1], - minChildSize: 0.0, - initialChildSize: MIN_SIZE, - controller: sheetController, - builder: (context, controller) => - ModalSheet( - miuiIsGapless: true, - child: SingleChildScrollView( - controller: controller, - child: quickShareTasks.isEmpty - ? buildEmptyState() - : buildActiveSharesList(), - ), - ), + material: (context, _) => AnimatedBuilder( + animation: offsetProgress, + builder: (context, child) => Transform.translate( + offset: Offset(-_xOffset * (1 - offsetProgress.value), 0), + child: child, + ), + child: DraggableScrollableSheet( + snap: true, + snapSizes: const [MIN_SIZE, 1], + minChildSize: 0.0, + initialChildSize: MIN_SIZE, + controller: sheetController, + builder: (context, controller) => ModalSheet( + miuiIsGapless: true, + child: SingleChildScrollView( + controller: controller, + child: quickShareTasks.isEmpty + ? buildEmptyState() + : buildActiveSharesList(), ), ), - cupertino: (context, _) => - DraggableScrollableSheet( - snap: true, - snapSizes: const [MIN_SIZE, 1], - minChildSize: 0.0, - initialChildSize: MIN_SIZE, - controller: sheetController, - builder: (context, controller) => - AnimatedBuilder( - animation: offsetProgress, - child: SingleChildScrollView( - controller: controller, - child: quickShareTasks.isEmpty - ? buildEmptyState() - : buildActiveSharesList(), - ), - builder: (context, child) => - ModalSheet( - cupertinoPadding: EdgeInsets.only( - top: lerpDouble( - MEDIUM_SPACE, - HUGE_SPACE, - offsetProgress.value, - ) ?? - 0, - left: MEDIUM_SPACE, - right: MEDIUM_SPACE, - ), - child: child ?? const SizedBox.shrink(), - ), - ), + ), + ), + cupertino: (context, _) => DraggableScrollableSheet( + snap: true, + snapSizes: const [MIN_SIZE, 1], + minChildSize: 0.0, + initialChildSize: MIN_SIZE, + controller: sheetController, + builder: (context, controller) => AnimatedBuilder( + animation: offsetProgress, + child: SingleChildScrollView( + controller: controller, + child: quickShareTasks.isEmpty + ? buildEmptyState() + : buildActiveSharesList(), ), + builder: (context, child) => ModalSheet( + cupertinoPadding: EdgeInsets.only( + top: lerpDouble( + MEDIUM_SPACE, + HUGE_SPACE, + offsetProgress.value, + ) ?? + 0, + left: MEDIUM_SPACE, + right: MEDIUM_SPACE, + ), + child: child ?? const SizedBox.shrink(), + ), + ), + ), ), ); } diff --git a/lib/screens/locations_overview_screen_widgets/LocationFetchers.dart b/lib/screens/locations_overview_screen_widgets/LocationFetchers.dart new file mode 100644 index 00000000..8891de89 --- /dev/null +++ b/lib/screens/locations_overview_screen_widgets/LocationFetchers.dart @@ -0,0 +1,74 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/services/location_fetcher_service/Fetcher.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/view_service/index.dart'; + +class LocationFetchers extends ChangeNotifier { + final Set _fetchers = {}; + + UnmodifiableSetView get fetchers => UnmodifiableSetView(_fetchers); + + LocationFetchers(final List views,) { + addAll(views); + } + + void addLocationUpdatesListener(final VoidCallback callback,) { + for (final fetcher in _fetchers) { + fetcher.addListener(callback); + } + } + + void removeLocationUpdatesListener(final VoidCallback callback,) { + for (final fetcher in _fetchers) { + fetcher.removeListener(callback); + } + } + + void add(final TaskView view) { + if (_fetchers.any((fetcher) => fetcher.view == view)) { + return; + } + + _fetchers.add(Fetcher(view)); + } + + void remove(final TaskView view) { + final fetcher = findFetcher(view); + + if (fetcher != null) { + fetcher.dispose(); + _fetchers.remove(fetcher); + } + } + + void addAll(final List views) { + for (final view in views) { + add(view); + } + } + + void fetchPreviewLocations() { + FlutterLogs.logInfo( + LOG_TAG, + "Location Fetchers", + "Fetching preview locations for ${_fetchers.length} tasks...", + ); + + for (final fetcher in _fetchers) { + if (!fetcher.hasFetchedPreviewLocations) { + fetcher.fetchPreviewLocations(); + } + } + } + + Fetcher? findFetcher(final TaskView view) { + return _fetchers.firstWhereOrNull((fetcher) => fetcher.view == view); + } + + List getLocations(final TaskView view) { + return findFetcher(view)?.sortedLocations ?? []; + } +} diff --git a/lib/screens/locations_overview_screen_widgets/OutOfBoundMarker.dart b/lib/screens/locations_overview_screen_widgets/OutOfBoundMarker.dart index 38dc6acd..4a7a0422 100644 --- a/lib/screens/locations_overview_screen_widgets/OutOfBoundMarker.dart +++ b/lib/screens/locations_overview_screen_widgets/OutOfBoundMarker.dart @@ -11,8 +11,8 @@ import 'package:geolocator/geolocator.dart'; import "package:latlong2/latlong.dart"; import 'package:locus/constants/spacing.dart'; import 'package:locus/services/location_point_service.dart'; -import 'package:locus/services/settings_service.dart'; -import 'package:locus/services/view_service.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/services/view_service/index.dart'; import 'package:provider/provider.dart'; import 'package:simple_shadow/simple_shadow.dart'; @@ -111,7 +111,9 @@ class _OutOfBoundMarkerState extends State } void _updateSizes() { - final size = MediaQuery.of(context).size; + final size = MediaQuery + .of(context) + .size; setState(() { this.size = size; @@ -145,11 +147,11 @@ class _OutOfBoundMarkerState extends State } final xPercentage = - ((widget.lastViewLocation.longitude - west) / (east - west)) - .clamp(1 - xAvailablePercentage, xAvailablePercentage); + ((widget.lastViewLocation.longitude - west) / (east - west)) + .clamp(1 - xAvailablePercentage, xAvailablePercentage); final yPercentage = - ((widget.lastViewLocation.latitude - north) / (south - north)) - .clamp(yAvailablePercentageStart, yAvailablePercentageEnd); + ((widget.lastViewLocation.latitude - north) / (south - north)) + .clamp(yAvailablePercentageStart, yAvailablePercentageEnd); // Calculate the rotation between marker and last location final markerLongitude = west + xPercentage * (east - west); @@ -171,8 +173,8 @@ class _OutOfBoundMarkerState extends State final width = size.width - OUT_OF_BOUND_MARKER_X_PADDING - OUT_OF_BOUND_MARKER_SIZE; final height = usesOpenStreetMap && - (xPercentage * size.width > bottomRightMapActionsHeight && - yPercentage > 0.5) + (xPercentage * size.width > bottomRightMapActionsHeight && + yPercentage > 0.5) ? size.height - (FAB_SIZE + FAB_MARGIN) * 2 : size.height; diff --git a/lib/screens/locations_overview_screen_widgets/ShareLocationSheet.dart b/lib/screens/locations_overview_screen_widgets/ShareLocationSheet.dart index 7f4cabff..75a24131 100644 --- a/lib/screens/locations_overview_screen_widgets/ShareLocationSheet.dart +++ b/lib/screens/locations_overview_screen_widgets/ShareLocationSheet.dart @@ -6,6 +6,7 @@ import 'package:flutter_logs/flutter_logs.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:intl/intl.dart'; import 'package:locus/constants/values.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:locus/services/timers_service.dart'; import 'package:locus/utils/date.dart'; import 'package:locus/utils/theme.dart'; @@ -15,10 +16,11 @@ import 'package:locus/widgets/ModalSheet.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:locus/widgets/ModalSheetContent.dart'; import 'package:locus/widgets/PlatformRadioTile.dart'; +import 'package:locus/widgets/RequestBatteryOptimizationsDisabledMixin.dart'; +import 'package:locus/widgets/RequestLocationPermissionMixin.dart'; import 'package:provider/provider.dart'; -import '../../services/settings_service.dart'; -import '../../services/task_service.dart'; +import '../../services/task_service/index.dart'; enum ShareType { untilTurnOff, @@ -32,7 +34,10 @@ class ShareLocationSheet extends StatefulWidget { State createState() => _ShareLocationSheetState(); } -class _ShareLocationSheetState extends State { +class _ShareLocationSheetState extends State + with + RequestLocationPermissionMixin, + RequestBatteryOptimizationsDisabledMixin { final hoursFormKey = GlobalKey(); final hoursController = TextEditingController(text: "1"); @@ -66,6 +71,34 @@ class _ShareLocationSheetState extends State { isLoading = true; }); + FlutterLogs.logInfo( + LOG_TAG, + "Quick Location Share", + "Checking permission", + ); + + if (!await showLocationPermissionDialog(askForAlways: true)) { + FlutterLogs.logInfo( + LOG_TAG, + "Quick Location Share", + "Permission not granted. Aborting.", + ); + + setState(() { + isLoading = false; + }); + + return; + } + + FlutterLogs.logInfo( + LOG_TAG, + "Quick Location Share", + "Checking battery saver", + ); + + await showDisableBatteryOptimizationsDialog(); + FlutterLogs.logInfo( LOG_TAG, "Quick Location Share", diff --git a/lib/screens/locations_overview_screen_widgets/TaskTile.dart b/lib/screens/locations_overview_screen_widgets/TaskTile.dart index 3874223b..6b674cb2 100644 --- a/lib/screens/locations_overview_screen_widgets/TaskTile.dart +++ b/lib/screens/locations_overview_screen_widgets/TaskTile.dart @@ -3,7 +3,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' hide PlatformListTile; import 'package:intl/intl.dart'; import 'package:locus/screens/TaskDetailScreen.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:locus/services/timers_service.dart'; import 'package:locus/utils/date.dart'; @@ -76,8 +76,7 @@ class _TaskTileState extends State with TaskLinkGenerationMixin { return PlatformListTile( title: Text(widget.task.name), - subtitle: widget.task.timers.length == 1 && - widget.task.timers[0] is DurationTimer && + subtitle: widget.task.isFiniteQuickShare && (widget.task.timers[0] as DurationTimer).startDate != null ? Text( formatStartDate( @@ -96,6 +95,20 @@ class _TaskTileState extends State with TaskLinkGenerationMixin { ), onPressed: generateLink, ), + PlatformPopupMenuItem( + label: PlatformListTile( + leading: Icon(context.platformIcons.info), + title: Text(l10n.taskAction_showDetails), + ), + onPressed: () { + pushRoute( + context, + (context) => TaskDetailScreen( + task: widget.task, + ), + ); + }, + ) ], ), leading: FutureBuilder( @@ -118,14 +131,6 @@ class _TaskTileState extends State with TaskLinkGenerationMixin { : null, ), ), - onTap: () { - pushRoute( - context, - (context) => TaskDetailScreen( - task: widget.task, - ), - ); - }, ); } } diff --git a/lib/screens/locations_overview_screen_widgets/ViewDetails.dart b/lib/screens/locations_overview_screen_widgets/ViewDetails.dart index 7f23c8ed..b10b1855 100644 --- a/lib/screens/locations_overview_screen_widgets/ViewDetails.dart +++ b/lib/screens/locations_overview_screen_widgets/ViewDetails.dart @@ -2,18 +2,18 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:geolocator/geolocator.dart'; import 'package:get_time_ago/get_time_ago.dart'; -import 'package:locus/services/view_service.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:locus/utils/navigation.dart'; import 'package:latlong2/latlong.dart'; - +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/date.dart'; import 'package:locus/utils/location/index.dart'; import 'package:locus/utils/permissions/has-granted.dart'; -import 'package:locus/utils/permissions/request.dart'; +import 'package:locus/widgets/OpenInMaps.dart'; + import '../../constants/spacing.dart'; import '../../services/location_point_service.dart'; import '../../utils/icon.dart'; @@ -21,12 +21,11 @@ import '../../utils/theme.dart'; import '../../widgets/BentoGridElement.dart'; import '../../widgets/LocusFlutterMap.dart'; import '../../widgets/RequestLocationPermissionMixin.dart'; -import '../ViewDetailScreen.dart'; class ViewDetails extends StatefulWidget { final TaskView? view; final LocationPointService? location; - final void Function(LatLng position) onGoToPosition; + final void Function(LocationPointService position) onGoToPosition; const ViewDetails({ required this.view, @@ -59,14 +58,14 @@ class _ViewDetailsState extends State { child: SizedBox( height: 200, child: LocusFlutterMap( - options: MapOptions( + flutterMapOptions: MapOptions( center: LatLng( lastLocation.latitude, lastLocation.longitude, ), zoom: 13, ), - children: [ + flutterChildren: [ MarkerLayer( markers: [ Marker( @@ -115,14 +114,6 @@ class _ViewDetailsState extends State { ), DistanceBentoElement( lastLocation: lastLocation, - onTap: () { - widget.onGoToPosition( - LatLng( - lastLocation.latitude, - lastLocation.longitude, - ), - ); - }, ), BentoGridElement( title: lastLocation.altitude == null @@ -188,10 +179,8 @@ class _ViewDetailsState extends State { class DistanceBentoElement extends StatefulWidget { final LocationPointService lastLocation; - final VoidCallback onTap; const DistanceBentoElement({ - required this.onTap, required this.lastLocation, super.key, }); @@ -209,6 +198,10 @@ class _DistanceBentoElementState extends State void fetchCurrentPosition() async { _positionStream = getLastAndCurrentPosition(updateLocation: true) ..listen((position) { + if (!mounted) { + return; + } + setState(() { currentPosition = position; }); @@ -242,19 +235,15 @@ class _DistanceBentoElementState extends State final l10n = AppLocalizations.of(context); return BentoGridElement( - onTap: hasGrantedPermission == false - ? () async { - final hasGranted = await requestBasicLocationPermission(); - - if (hasGranted) { - fetchCurrentPosition(); - - setState(() { - hasGrantedPermission = true; - }); - } - } - : widget.onTap, + onTap: () { + showPlatformModalSheet( + context: context, + material: MaterialModalSheetData(), + builder: (context) => OpenInMaps( + destination: widget.lastLocation.asCoords(), + ), + ); + }, title: (() { if (!hasGrantedPermission) { return l10n.locations_values_distance_permissionRequired; @@ -264,16 +253,25 @@ class _DistanceBentoElementState extends State return l10n.loading; } + final distanceInMeters = Geolocator.distanceBetween( + currentPosition!.latitude, + currentPosition!.longitude, + widget.lastLocation.latitude, + widget.lastLocation.longitude, + ); + + if (distanceInMeters < 10) { + return l10n.locations_values_distance_nearby; + } + + if (distanceInMeters < 1000) { + return l10n.locations_values_distance_m( + distanceInMeters.toStringAsFixed(0).toString(), + ); + } + return l10n.locations_values_distance_km( - (Geolocator.distanceBetween( - currentPosition!.latitude, - currentPosition!.longitude, - widget.lastLocation.latitude, - widget.lastLocation.longitude, - ) / - 1000) - .floor() - .toString(), + (distanceInMeters / 1000).toStringAsFixed(0), ); })(), type: hasGrantedPermission && currentPosition != null @@ -309,6 +307,7 @@ class LastLocationBentoElement extends StatefulWidget { class _LastLocationBentoElementState extends State { late final Timer _timer; + bool showAbsolute = false; @override void initState() { @@ -332,16 +331,17 @@ class _LastLocationBentoElementState extends State { return BentoGridElement( onTap: () { - pushRoute( - context, - (context) => ViewDetailScreen(view: widget.view), - ); + setState(() { + showAbsolute = !showAbsolute; + }); }, - title: GetTimeAgo.parse( - DateTime.now().subtract( - DateTime.now().difference(widget.lastLocation.createdAt), - ), - ), + title: showAbsolute + ? formatDateTimeHumanReadable(widget.lastLocation.createdAt) + : GetTimeAgo.parse( + DateTime.now().subtract( + DateTime.now().difference(widget.lastLocation.createdAt), + ), + ), icon: Icons.location_on_rounded, description: l10n.locations_values_lastLocation_description, ); diff --git a/lib/screens/locations_overview_screen_widgets/ViewDetailsSheet.dart b/lib/screens/locations_overview_screen_widgets/ViewDetailsSheet.dart index ca06f0e9..b0326f1c 100644 --- a/lib/screens/locations_overview_screen_widgets/ViewDetailsSheet.dart +++ b/lib/screens/locations_overview_screen_widgets/ViewDetailsSheet.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:latlong2/latlong.dart'; import 'package:locus/screens/locations_overview_screen_widgets/ViewDetails.dart'; import 'package:locus/services/location_point_service.dart'; -import 'package:locus/services/view_service.dart'; +import 'package:locus/services/view_service/index.dart'; import 'package:locus/widgets/ModalSheet.dart'; import '../../constants/spacing.dart'; @@ -13,12 +12,14 @@ import '../../widgets/SimpleAddressFetcher.dart'; class ViewDetailsSheet extends StatefulWidget { final TaskView? view; final List? locations; - final void Function(LatLng position) onGoToPosition; + final void Function(LocationPointService position) onGoToPosition; + final void Function(LocationPointService location) onVisibleLocationChange; const ViewDetailsSheet({ required this.view, required this.locations, required this.onGoToPosition, + required this.onVisibleLocationChange, super.key, }); @@ -139,10 +140,16 @@ class _ViewDetailsSheetState extends State { SizedBox( height: 120, child: PageView.builder( - physics: isExpanded - ? null - : const NeverScrollableScrollPhysics(), + physics: null, onPageChanged: (index) { + final location = widget.locations![index]; + + widget.onVisibleLocationChange(location); + + if (!isExpanded) { + widget.onGoToPosition(location); + } + setState(() { locationIndex = index; }); @@ -194,8 +201,11 @@ class _ViewDetailsSheetState extends State { ), ) ] else - Text( - l10n.locationFetchEmptyError, + Padding( + padding: const EdgeInsets.all(MEDIUM_SPACE), + child: Text( + l10n.locationFetchEmptyError, + ), ) ], ), diff --git a/lib/screens/locations_overview_screen_widgets/ViewLocationPopup.dart b/lib/screens/locations_overview_screen_widgets/ViewLocationPopup.dart index 4c11d8d1..316d8844 100644 --- a/lib/screens/locations_overview_screen_widgets/ViewLocationPopup.dart +++ b/lib/screens/locations_overview_screen_widgets/ViewLocationPopup.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:locus/services/view_service.dart'; +import 'package:locus/services/view_service/index.dart'; import 'package:locus/widgets/Paper.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:locus/widgets/SimpleAddressFetcher.dart'; @@ -35,8 +35,7 @@ class ViewLocationPopup extends StatelessWidget { ), child: Paper( width: null, - child: - Padding( + child: Padding( padding: const EdgeInsets.all(MEDIUM_SPACE), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/locations_overview_screen_widgets/view_location_fetcher.dart b/lib/screens/locations_overview_screen_widgets/view_location_fetcher.dart deleted file mode 100644 index c8cd368d..00000000 --- a/lib/screens/locations_overview_screen_widgets/view_location_fetcher.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import '../../services/location_point_service.dart'; -import '../../services/view_service.dart'; - -class ViewLocationFetcher extends ChangeNotifier { - final Iterable views; - final Map> _locations = {}; - final List _getLocationsUnsubscribers = []; - - bool _mounted = true; - - Map> get locations => _locations; - - bool _isLoading = false; - - bool get isLoading => _isLoading; - - ViewLocationFetcher(this.views); - - bool get hasMultipleLocationViews => _locations.keys.length > 1; - - // If _fetchLast24Hours fails (no location fetched), we want to get the last location - void _fetchLastLocation(final TaskView view) { - _getLocationsUnsubscribers.add( - view.getLocations( - limit: 1, - onLocationFetched: (location) { - if (!_mounted) { - return; - } - - _locations[view] = [ - ...(locations[view] ?? []), - location, - ]; - }, - onEnd: () { - if (!_mounted) { - return; - } - - _locations[view] = _locations[view]! - ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); - - _setIsLoading(_locations.keys.length == views.length); - }, - ), - ); - } - - void _fetchView( - final TaskView view, { - final DateTime? from, - final int? limit, - }) { - assert(!_locations.containsKey(view)); - - _getLocationsUnsubscribers.add( - view.getLocations( - from: from, - limit: limit, - onLocationFetched: (location) { - if (!_mounted) { - return; - } - - _locations[view] = List.from( - [..._locations[view] ?? [], location], - ); - }, - onEnd: () { - if (!_mounted) { - return; - } - - _locations[view] = _locations[view]! - ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); - - _setIsLoading(_locations.keys.length == views.length); - }, - onEmptyEnd: () { - _fetchLastLocation(view); - }, - ), - ); - } - - void _fetchLast24Hours() { - for (final view in views) { - _fetchView( - view, - from: DateTime.now().subtract(const Duration(hours: 24)), - ); - } - } - - void fetchLocations() { - _setIsLoading(true); - - _fetchLast24Hours(); - } - - void _setIsLoading(final bool isLoading) { - _isLoading = isLoading; - notifyListeners(); - } - - void addView(final TaskView view) { - _setIsLoading(true); - - _fetchView( - view, - from: DateTime.now().subtract(const Duration(hours: 24)), - ); - } - - @override - void dispose() { - for (final unsubscribe in _getLocationsUnsubscribers) { - unsubscribe(); - } - - _mounted = false; - super.dispose(); - } -} diff --git a/lib/screens/settings_screen_widgets/ImportSheet.dart b/lib/screens/settings_screen_widgets/ImportSheet.dart index b599be1f..2af55329 100644 --- a/lib/screens/settings_screen_widgets/ImportSheet.dart +++ b/lib/screens/settings_screen_widgets/ImportSheet.dart @@ -9,9 +9,9 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' hide PlatformListTile; import 'package:locus/constants/app.dart'; import 'package:locus/screens/welcome_screen_widgets/TransferReceiverScreen.dart'; -import 'package:locus/services/settings_service.dart'; -import 'package:locus/services/task_service.dart'; -import 'package:locus/services/view_service.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'; import 'package:locus/utils/PageRoute.dart'; import 'package:locus/widgets/PlatformFlavorWidget.dart'; @@ -22,10 +22,10 @@ import '../../widgets/PlatformListTile.dart'; class ImportSheet extends StatefulWidget { final void Function( - TaskService taskService, - ViewService viewService, - SettingsService settings, - ) onImport; + TaskService taskService, + ViewService viewService, + SettingsService settings, + ) onImport; const ImportSheet({ required this.onImport, @@ -44,29 +44,32 @@ class _ImportSheetState extends State { final shouldImport = await showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - material: (context, __) => MaterialAlertDialogData( - icon: PlatformFlavorWidget( - material: (context, _) => const Icon(Icons.warning_rounded), - cupertino: (context, _) => - const Icon(CupertinoIcons.exclamationmark_triangle_fill), - ), - ), - title: Text(l10n.settingsScreen_import_confirmation_title), - content: Text(l10n.settingsScreen_import_confirmation_description), - actions: createCancellableDialogActions( - context, - [ - PlatformDialogAction( - material: (context, _) => MaterialDialogActionData( - icon: const Icon(Icons.download_rounded), - ), - child: Text(l10n.settingsScreen_import_confirmation_confirm), - onPressed: () => Navigator.pop(context, true), + builder: (context) => + PlatformAlertDialog( + material: (context, __) => + MaterialAlertDialogData( + icon: PlatformFlavorWidget( + material: (context, _) => const Icon(Icons.warning_rounded), + cupertino: (context, _) => + const Icon(CupertinoIcons.exclamationmark_triangle_fill), + ), + ), + title: Text(l10n.settingsScreen_import_confirmation_title), + content: Text(l10n.settingsScreen_import_confirmation_description), + actions: createCancellableDialogActions( + context, + [ + PlatformDialogAction( + material: (context, _) => + MaterialDialogActionData( + icon: const Icon(Icons.download_rounded), + ), + child: Text(l10n.settingsScreen_import_confirmation_confirm), + onPressed: () => Navigator.pop(context, true), + ), + ], ), - ], - ), - ), + ), ); if (shouldImport != true || !mounted) { @@ -150,24 +153,25 @@ class _ImportSheetState extends State { ), Platform.isAndroid && isGMSFlavor ? PlatformListTile( - leading: PlatformWidget( - material: (_, __) => - const Icon(Icons.phonelink_setup_rounded), - cupertino: (_, __) => - const Icon(CupertinoIcons.device_phone_portrait), - ), - title: Text(l10n.settingsScreen_import_transfer), - onTap: () { - Navigator.push( - context, - NativePageRoute( - context: context, - builder: (context) => TransferReceiverScreen( - onContentReceived: importRawData), - ), - ); - }, - ) + leading: PlatformWidget( + material: (_, __) => + const Icon(Icons.phonelink_setup_rounded), + cupertino: (_, __) => + const Icon(CupertinoIcons.device_phone_portrait), + ), + title: Text(l10n.settingsScreen_import_transfer), + onTap: () { + Navigator.push( + context, + NativePageRoute( + context: context, + builder: (context) => + TransferReceiverScreen( + onContentReceived: importRawData), + ), + ); + }, + ) : const SizedBox.shrink(), ], ), diff --git a/lib/screens/settings_screen_widgets/TransferSenderScreen.dart b/lib/screens/settings_screen_widgets/TransferSenderScreen.dart index 33234734..90780db8 100644 --- a/lib/screens/settings_screen_widgets/TransferSenderScreen.dart +++ b/lib/screens/settings_screen_widgets/TransferSenderScreen.dart @@ -8,8 +8,8 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/constants/app.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/constants/values.dart'; -import 'package:locus/services/settings_service.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/utils/import_export_handler.dart'; import 'package:locus/utils/permissions/mixins.dart'; import 'package:locus/utils/theme.dart'; @@ -20,7 +20,7 @@ import 'package:nearby_connections/nearby_connections.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; -import '../../services/view_service.dart'; +import '../../services/view_service/index.dart'; class TransferSenderScreen extends StatefulWidget { const TransferSenderScreen({Key? key}) : super(key: key); @@ -126,8 +126,7 @@ class _TransferSenderScreenState extends State final data = Uint8List.fromList(content.codeUnits); await Nearby().sendBytesPayload(connectionID!, data); - } catch (_) { - } finally { + } catch (_) {} finally { setState(() { isSending = false; }); diff --git a/lib/screens/settings_screen_widgets/UseRealtimeUpdatesTile.dart b/lib/screens/settings_screen_widgets/UseRealtimeUpdatesTile.dart new file mode 100644 index 00000000..2ed53ae6 --- /dev/null +++ b/lib/screens/settings_screen_widgets/UseRealtimeUpdatesTile.dart @@ -0,0 +1,67 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/utils/theme.dart'; +import 'package:provider/provider.dart'; +import 'package:settings_ui/settings_ui.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class UseRealtimeUpdatesTile extends AbstractSettingsTile { + const UseRealtimeUpdatesTile({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final settings = context.watch(); + + return SettingsTile.switchTile( + initialValue: settings.useRealtimeUpdates, + onToggle: (newValue) async { + if (!newValue) { + final confirm = await showPlatformDialog( + context: context, + barrierDismissible: true, + builder: (context) => PlatformAlertDialog( + title: Text( + l10n.settingsScreen_settings_useRealtimeUpdates_dialog_title), + material: (_, __) => MaterialAlertDialogData( + icon: settings.isMIUI() + ? const Icon(CupertinoIcons.exclamationmark_triangle_fill) + : const Icon(Icons.warning_rounded), + ), + content: Text( + l10n.settingsScreen_settings_useRealtimeUpdates_dialog_message, + ), + actions: createCancellableDialogActions( + context, + [ + PlatformDialogAction( + child: Text(l10n + .settingsScreen_settings_useRealtimeUpdates_dialog_confirm), + onPressed: () { + Navigator.of(context).pop(true); + }, + ) + ], + ), + ), + ); + + if (!context.mounted || confirm != true) { + return; + } + } + + settings.setUseRealtimeUpdates(newValue); + settings.save(); + }, + title: Text( + l10n.settingsScreen_settings_useRealtimeUpdates_label, + ), + description: Text( + l10n.settingsScreen_settings_useRealtimeUpdates_description, + ), + ); + } +} diff --git a/lib/screens/shares_overview_screen_widgets/TaskTile.dart b/lib/screens/shares_overview_screen_widgets/TaskTile.dart index 72f4ef1c..e6ac3e36 100644 --- a/lib/screens/shares_overview_screen_widgets/TaskTile.dart +++ b/lib/screens/shares_overview_screen_widgets/TaskTile.dart @@ -4,7 +4,7 @@ import 'package:flutter_logs/flutter_logs.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' hide PlatformListTile; import 'package:locus/constants/values.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/utils/PageRoute.dart'; import 'package:provider/provider.dart'; @@ -72,7 +72,7 @@ class _TaskTileState extends State with TaskLinkGenerationMixin { ); final nextEndDate = widget.task.nextEndDate(); - widget.task.publishCurrentPosition(); + widget.task.publisher.publishCurrentPosition(); if (!mounted) { return; diff --git a/lib/screens/shares_overview_screen_widgets/ViewTile.dart b/lib/screens/shares_overview_screen_widgets/ViewTile.dart index 2e0f0d7e..880a1ee5 100644 --- a/lib/screens/shares_overview_screen_widgets/ViewTile.dart +++ b/lib/screens/shares_overview_screen_widgets/ViewTile.dart @@ -2,14 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' hide PlatformListTile; -import 'package:locus/services/view_service.dart'; +import 'package:locus/services/view_service/index.dart'; import 'package:locus/utils/PageRoute.dart'; import 'package:locus/utils/theme.dart'; import 'package:locus/widgets/PlatformPopup.dart'; import 'package:provider/provider.dart'; import '../../widgets/PlatformListTile.dart'; -import '../ViewDetailScreen.dart'; +import '../ViewDetailsScreen.dart'; class ViewTile extends StatelessWidget { final TaskView view; @@ -38,30 +38,35 @@ class ViewTile extends StatelessWidget { final confirmDeletion = await showPlatformDialog( context: context, barrierDismissible: true, - builder: (context) => PlatformAlertDialog( - material: (_, __) => MaterialAlertDialogData( - icon: Icon(context.platformIcons.delete), - ), - title: Text(l10n.viewAction_delete_confirm_title(view.name)), - content: Text(l10n.actionNotUndoable), - actions: createCancellableDialogActions( - context, - [ - PlatformDialogAction( - onPressed: () { - Navigator.of(context).pop(true); - }, - material: (_, __) => MaterialDialogActionData( - icon: Icon(context.platformIcons.delete), - ), - cupertino: (_, __) => CupertinoDialogActionData( - isDestructiveAction: true, - ), - child: Text(l10n.deleteLabel), + builder: (context) => + PlatformAlertDialog( + material: (_, __) => + MaterialAlertDialogData( + icon: Icon(context.platformIcons.delete), + ), + title: Text( + l10n.viewAction_delete_confirm_title(view.name)), + content: Text(l10n.actionNotUndoable), + actions: createCancellableDialogActions( + context, + [ + PlatformDialogAction( + onPressed: () { + Navigator.of(context).pop(true); + }, + material: (_, __) => + MaterialDialogActionData( + icon: Icon(context.platformIcons.delete), + ), + cupertino: (_, __) => + CupertinoDialogActionData( + isDestructiveAction: true, + ), + child: Text(l10n.deleteLabel), + ), + ], ), - ], - ), - ), + ), ); if (confirmDeletion) { @@ -76,9 +81,10 @@ class ViewTile extends StatelessWidget { Navigator.of(context).push( NativePageRoute( context: context, - builder: (context) => ViewDetailScreen( - view: view, - ), + builder: (context) => + ViewDetailsScreen( + view: view, + ), ), ); }, diff --git a/lib/screens/shares_overview_screen_widgets/screens/TasksOverviewScreen.dart b/lib/screens/shares_overview_screen_widgets/screens/TasksOverviewScreen.dart index 47ed72e3..16537702 100644 --- a/lib/screens/shares_overview_screen_widgets/screens/TasksOverviewScreen.dart +++ b/lib/screens/shares_overview_screen_widgets/screens/TasksOverviewScreen.dart @@ -5,9 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/constants/spacing.dart'; -import 'package:locus/services/settings_service.dart'; -import 'package:locus/services/task_service.dart'; -import 'package:locus/services/view_service.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'; import 'package:locus/widgets/AppHint.dart'; import 'package:locus/widgets/ChipCaption.dart'; import 'package:provider/provider.dart'; @@ -79,103 +79,110 @@ class _TasksOverviewScreenState extends State children: [ if (taskService.tasks.isNotEmpty) PlatformWidget( - material: (context, __) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: MEDIUM_SPACE), - child: ChipCaption( - l10n.sharesOverviewScreen_tasksSection, - icon: Icons.task_rounded, - ), - ).animate().fadeIn(duration: 1.seconds), - ListView.builder( - shrinkWrap: true, - padding: const EdgeInsets.only(top: MEDIUM_SPACE), - physics: const NeverScrollableScrollPhysics(), - itemCount: taskService.tasks.length, - itemBuilder: (context, index) { - final task = taskService.tasks[index]; + material: (context, __) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MEDIUM_SPACE), + child: ChipCaption( + l10n.sharesOverviewScreen_tasksSection, + icon: Icons.task_rounded, + ), + ).animate().fadeIn(duration: 1.seconds), + ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(top: MEDIUM_SPACE), + physics: const NeverScrollableScrollPhysics(), + itemCount: taskService.tasks.length, + itemBuilder: (context, index) { + final task = taskService.tasks[index]; - return TaskTile( - task: task, - ) - .animate() - .then(delay: 100.ms * index) - .slide( + return TaskTile( + task: task, + ) + .animate() + .then(delay: 100.ms * index) + .slide( duration: 1.seconds, curve: Curves.easeOut, begin: const Offset(0, 0.2), ) - .fadeIn( + .fadeIn( delay: 100.ms, duration: 1.seconds, curve: Curves.easeOut, ); - }, - ), - ], - ), - cupertino: (context, __) => CupertinoListSection( - header: Text( - l10n.sharesOverviewScreen_tasksSection, - ), - children: taskService.tasks - .map( - (task) => TaskTile( - task: task, + }, ), + ], + ), + cupertino: (context, __) => + CupertinoListSection( + header: Text( + l10n.sharesOverviewScreen_tasksSection, + ), + children: taskService.tasks + .map( + (task) => + TaskTile( + task: task, + ), ) - .toList(), - ), + .toList(), + ), ), if (viewService.views.isNotEmpty) PlatformWidget( - material: (context, __) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: MEDIUM_SPACE), - child: ChipCaption( - l10n.sharesOverviewScreen_viewsSection, - icon: context.platformIcons.eyeSolid, - ), - ).animate().fadeIn(duration: 1.seconds), - ListView.builder( - shrinkWrap: true, - padding: const EdgeInsets.only(top: MEDIUM_SPACE), - physics: const NeverScrollableScrollPhysics(), - itemCount: viewService.views.length, - itemBuilder: (context, index) => ViewTile( - view: viewService.views[index], - ) - .animate() - .then(delay: 100.ms * index) - .slide( - duration: 1.seconds, - curve: Curves.easeOut, - begin: const Offset(0, 0.2), - ) - .fadeIn( - delay: 100.ms, - duration: 1.seconds, - curve: Curves.easeOut, + material: (context, __) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: MEDIUM_SPACE), + child: ChipCaption( + l10n.sharesOverviewScreen_viewsSection, + icon: context.platformIcons.eyeSolid, ), - ), - ], - ), - cupertino: (context, __) => CupertinoListSection( - header: Text(l10n.sharesOverviewScreen_viewsSection), - children: viewService.views - .map( - (view) => ViewTile( - view: view, + ).animate().fadeIn(duration: 1.seconds), + ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(top: MEDIUM_SPACE), + physics: const NeverScrollableScrollPhysics(), + itemCount: viewService.views.length, + itemBuilder: (context, index) => + ViewTile( + view: viewService.views[index], + ) + .animate() + .then(delay: 100.ms * index) + .slide( + duration: 1.seconds, + curve: Curves.easeOut, + begin: const Offset(0, 0.2), + ) + .fadeIn( + delay: 100.ms, + duration: 1.seconds, + curve: Curves.easeOut, + ), ), + ], + ), + cupertino: (context, __) => + CupertinoListSection( + header: Text(l10n.sharesOverviewScreen_viewsSection), + children: viewService.views + .map( + (view) => + ViewTile( + view: view, + ), ) - .toList(), - ), + .toList(), + ), ), ], ), diff --git a/lib/screens/task_detail_screen_widgets/Details.dart b/lib/screens/task_detail_screen_widgets/Details.dart index 326e5c89..97983ee1 100644 --- a/lib/screens/task_detail_screen_widgets/Details.dart +++ b/lib/screens/task_detail_screen_widgets/Details.dart @@ -8,7 +8,7 @@ import 'package:locus/screens/LocationPointsDetailsScreen.dart'; import 'package:locus/screens/task_detail_screen_widgets/ShareLocationButton.dart'; import 'package:locus/services/location_point_service.dart'; import 'package:locus/services/log_service.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/utils/theme.dart'; import 'package:locus/widgets/DetailInformationBox.dart'; import 'package:locus/widgets/RelaySelectSheet.dart'; @@ -20,14 +20,10 @@ import '../../widgets/PlatformListTile.dart'; import '../../widgets/TimerWidgetSheet.dart'; class Details extends StatefulWidget { - final List locations; final Task task; - final void Function() onGoBack; const Details({ - required this.locations, required this.task, - required this.onGoBack, Key? key, }) : super(key: key); @@ -70,24 +66,6 @@ class _DetailsState extends State
{ return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - PlatformTextButton( - material: (_, __) => MaterialTextButtonData( - style: ButtonStyle( - // Not rounded, but square - shape: MaterialStateProperty.all( - const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - ), - ), - padding: MaterialStateProperty.all( - const EdgeInsets.all(MEDIUM_SPACE), - ), - ), - icon: const Icon(Icons.arrow_upward_rounded), - ), - onPressed: widget.onGoBack, - child: Text(l10n.goBack), - ), Padding( padding: const EdgeInsets.all(MEDIUM_SPACE), child: Wrap( @@ -99,75 +77,6 @@ class _DetailsState extends State
{ task: widget.task, ), ), - DetailInformationBox( - title: l10n.taskDetails_lastKnownLocation, - child: widget.locations.isEmpty - ? Text(l10n.taskDetails_noLocations) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SimpleAddressFetcher( - location: widget.locations.last.asLatLng(), - ), - const SizedBox(height: MEDIUM_SPACE), - Tooltip( - message: - l10n.taskDetails_mostRecentLocationExplanation, - textAlign: TextAlign.center, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - context.platformIcons.time, - size: getIconSizeForBodyText(context), - ), - const SizedBox(width: TINY_SPACE), - Text( - widget.locations.last.createdAt.toString(), - style: getBodyTextTextStyle(context), - textAlign: TextAlign.start, - ), - ], - ), - ), - ], - ), - ), - GestureDetector( - onTap: widget.locations.isEmpty - ? null - : () { - Navigator.of(context).push( - PageRouteBuilder( - opaque: true, - fullscreenDialog: true, - barrierColor: Colors.black.withOpacity(0.7), - barrierDismissible: true, - pageBuilder: (context, _, __) => - LocationPointsDetailsScreen( - locations: widget.locations, - isPreview: false, - ), - ), - ); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.taskDetails_locationDetails, - textAlign: TextAlign.start, - style: getSubTitleTextStyle(context), - ), - const SizedBox(height: MEDIUM_SPACE), - LocationPointsDetailsScreen( - locations: widget.locations, - isPreview: true, - ), - ], - ), - ), DetailInformationBox( title: l10n.nostrRelaysLabel, child: Column( diff --git a/lib/screens/task_detail_screen_widgets/LocationDetails.dart b/lib/screens/task_detail_screen_widgets/LocationDetails.dart index ae9e579a..8e4d6b19 100644 --- a/lib/screens/task_detail_screen_widgets/LocationDetails.dart +++ b/lib/screens/task_detail_screen_widgets/LocationDetails.dart @@ -7,13 +7,13 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' hide PlatformListTile; import 'package:locus/constants/spacing.dart'; import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/utils/date.dart'; import 'package:locus/utils/icon.dart'; import 'package:locus/utils/theme.dart'; import 'package:locus/utils/ui-message/show-message.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:provider/provider.dart'; - -import '../../services/settings_service.dart'; import '../../widgets/PlatformListTile.dart'; class LocationDetails extends StatefulWidget { @@ -91,9 +91,7 @@ class _LocationDetailsState extends State { child: Align( alignment: Alignment.centerLeft, child: Text( - l10n.taskDetails_locationDetails_createdAt_value( - widget.location.createdAt, - ), + formatDateTimeHumanReadable(widget.location.createdAt), textAlign: TextAlign.start, style: getBodyTextTextStyle(context), ), diff --git a/lib/screens/task_detail_screen_widgets/ShareLocationButton.dart b/lib/screens/task_detail_screen_widgets/ShareLocationButton.dart index 502abb52..acb268c9 100644 --- a/lib/screens/task_detail_screen_widgets/ShareLocationButton.dart +++ b/lib/screens/task_detail_screen_widgets/ShareLocationButton.dart @@ -10,13 +10,13 @@ import 'package:locus/constants/app.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/constants/values.dart'; import 'package:locus/screens/task_detail_screen_widgets/SendViewByBluetooth.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/widgets/SingularElementDialog.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; -import '../../services/settings_service.dart'; import '../../utils/file.dart'; import '../../utils/theme.dart'; @@ -37,7 +37,8 @@ class _ShareLocationButtonState extends State { Future _createTempViewKeyFile() async { return createTempFile( - const Utf8Encoder().convert(await widget.task.generateViewKeyContent()), + const Utf8Encoder() + .convert(await widget.task.cryptography.generateViewKeyContent()), name: "viewkey.locus.json", ); } @@ -114,7 +115,8 @@ class _ShareLocationButtonState extends State { try { switch (shouldShare) { case "qr": - final url = await widget.task.generateLink(settings.getServerHost()); + final url = await widget.task.publisher + .generateLink(settings.getServerHost()); if (!mounted) { return; @@ -156,7 +158,8 @@ class _ShareLocationButtonState extends State { ); break; case "link": - final url = await widget.task.generateLink(settings.getServerHost()); + final url = await widget.task.publisher + .generateLink(settings.getServerHost()); await Share.share( url, @@ -164,7 +167,7 @@ class _ShareLocationButtonState extends State { ); break; case "bluetooth": - final data = await widget.task.generateViewKeyContent(); + final data = await widget.task.cryptography.generateViewKeyContent(); if (mounted) { await showPlatformModalSheet( diff --git a/lib/screens/view_alarm_screen_widgets/RadiusRegionMetaDataSheet.dart b/lib/screens/view_alarm_screen_widgets/GeoAlarmMetaDataSheet.dart similarity index 83% rename from lib/screens/view_alarm_screen_widgets/RadiusRegionMetaDataSheet.dart rename to lib/screens/view_alarm_screen_widgets/GeoAlarmMetaDataSheet.dart index 135e632a..c8fd3cbc 100644 --- a/lib/screens/view_alarm_screen_widgets/RadiusRegionMetaDataSheet.dart +++ b/lib/screens/view_alarm_screen_widgets/GeoAlarmMetaDataSheet.dart @@ -5,31 +5,31 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' hide PlatformListTile; import 'package:latlong2/latlong.dart'; import 'package:locus/constants/spacing.dart'; -import 'package:locus/services/location_alarm_service.dart'; +import 'package:locus/services/location_alarm_service/enums.dart'; +import 'package:locus/services/location_alarm_service/index.dart'; import 'package:locus/utils/theme.dart'; import '../../widgets/ModalSheet.dart'; import '../../widgets/PlatformListTile.dart'; -class RadiusRegionMetaDataSheet extends StatefulWidget { +class GeoAlarmMetaDataSheet extends StatefulWidget { final LatLng center; final double radius; - const RadiusRegionMetaDataSheet({ + const GeoAlarmMetaDataSheet({ required this.center, required this.radius, super.key, }); @override - State createState() => - _RadiusRegionMetaDataSheetState(); + State createState() => _GeoAlarmMetaDataSheetState(); } -class _RadiusRegionMetaDataSheetState extends State { +class _GeoAlarmMetaDataSheetState extends State { final _nameController = TextEditingController(); final _formKey = GlobalKey(); - RadiusBasedRegionLocationAlarmType? _type; + LocationRadiusBasedTriggerType? _type; @override void dispose() { @@ -55,7 +55,7 @@ class _RadiusRegionMetaDataSheetState extends State { PlatformListTile( onTap: () { setState(() { - _type = RadiusBasedRegionLocationAlarmType.whenEnter; + _type = LocationRadiusBasedTriggerType.whenEnter; }); }, leading: const Icon(Icons.arrow_circle_right_rounded), @@ -65,7 +65,7 @@ class _RadiusRegionMetaDataSheetState extends State { PlatformListTile( onTap: () { setState(() { - _type = RadiusBasedRegionLocationAlarmType.whenLeave; + _type = LocationRadiusBasedTriggerType.whenLeave; }); }, leading: const Icon(Icons.arrow_circle_left_rounded), @@ -75,7 +75,7 @@ class _RadiusRegionMetaDataSheetState extends State { ] : [ Text( - l10n.location_addAlarm_radiusBased_name_description, + l10n.location_addAlarm_geo_name_description, style: getSubTitleTextStyle(context), ), const SizedBox(height: MEDIUM_SPACE), @@ -104,7 +104,7 @@ class _RadiusRegionMetaDataSheetState extends State { if (_formKey.currentState!.validate()) { Navigator.pop( context, - RadiusBasedRegionLocationAlarm.create( + GeoLocationAlarm.create( zoneName: _nameController.text, center: widget.center, radius: widget.radius, diff --git a/lib/screens/view_alarm_screen_widgets/GeoLocationAlarmPreview.dart b/lib/screens/view_alarm_screen_widgets/GeoLocationAlarmPreview.dart new file mode 100644 index 00000000..ad9477e7 --- /dev/null +++ b/lib/screens/view_alarm_screen_widgets/GeoLocationAlarmPreview.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/constants/spacing.dart'; +import 'package:locus/screens/locations_overview_screen_widgets/LocationFetchers.dart'; +import 'package:locus/services/location_alarm_service/index.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/map.dart'; +import 'package:locus/widgets/LocusFlutterMap.dart'; +import 'package:provider/provider.dart'; + +class GeoLocationAlarmPreview extends StatelessWidget { + final TaskView view; + final GeoLocationAlarm alarm; + final VoidCallback onDelete; + + const GeoLocationAlarmPreview({ + super.key, + required this.view, + required this.alarm, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final locationFetchers = context.watch(); + final lastLocation = + locationFetchers.getLocations(view).lastOrNull?.asLatLng(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + PlatformListTile( + title: Text(alarm.zoneName), + leading: getIconForLocationRadiusBasedTrigger(context, alarm.type), + trailing: PlatformIconButton( + icon: Icon(context.platformIcons.delete), + onPressed: onDelete, + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(LARGE_SPACE), + child: SizedBox( + width: double.infinity, + height: 200, + child: IgnorePointer( + ignoring: true, + child: LocusFlutterMap( + flutterMapOptions: MapOptions( + center: alarm.center, + maxZoom: 18, + // create zoom based of radius + zoom: getZoomLevelForRadius(alarm.radius), + ), + flutterChildren: [ + CircleLayer( + circles: [ + CircleMarker( + point: alarm.center, + useRadiusInMeter: true, + color: Colors.red.withOpacity(0.3), + borderStrokeWidth: 5, + borderColor: Colors.red, + radius: alarm.radius, + ), + if (lastLocation != null) ...[ + CircleMarker( + point: lastLocation, + useRadiusInMeter: false, + color: Colors.white, + radius: 7, + ), + CircleMarker( + point: lastLocation, + useRadiusInMeter: false, + color: Colors.cyan, + radius: 5, + ), + ], + ], + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/view_alarm_screen_widgets/LocationRadiusSelectorMap.dart b/lib/screens/view_alarm_screen_widgets/LocationRadiusSelectorMap.dart new file mode 100644 index 00000000..48925de8 --- /dev/null +++ b/lib/screens/view_alarm_screen_widgets/LocationRadiusSelectorMap.dart @@ -0,0 +1,257 @@ +import 'dart:math'; + +import 'package:apple_maps_flutter/apple_maps_flutter.dart' as apple_maps; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_location_marker/flutter_map_location_marker.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:locus/constants/spacing.dart'; +import 'package:locus/widgets/LocationsMap.dart'; +import 'package:locus/widgets/LocusFlutterMap.dart'; +import 'package:locus/widgets/MapBanner.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:vibration/vibration.dart'; + +const INITIAL_RADIUS = 50.0; + +class LocationRadiusSelectorMap extends StatefulWidget { + final MapController? flutterMapController; + final apple_maps.AppleMapController? appleMapController; + final void Function(apple_maps.AppleMapController controller)? + onAppleMapCreated; + final LatLng? center; + final double? radius; + final void Function(LatLng)? onLocationChange; + final void Function(double)? onRadiusChange; + final bool enableRealTimeRadiusUpdate; + final List children; + + const LocationRadiusSelectorMap({ + super.key, + this.flutterMapController, + this.appleMapController, + this.onLocationChange, + this.onRadiusChange, + this.onAppleMapCreated, + this.center, + this.radius, + this.enableRealTimeRadiusUpdate = false, + this.children = const [], + }); + + @override + State createState() => + _LocationRadiusSelectorMapState(); +} + +class _LocationRadiusSelectorMapState extends State { + LatLng? center; + double? radius; + + bool isInScaleMode = false; + + double previousScale = 1; + + @override + void initState() { + super.initState(); + + radius = widget.radius?.toDouble(); + center = widget.center; + } + + @override + void didUpdateWidget(covariant LocationRadiusSelectorMap oldWidget) { + super.didUpdateWidget(oldWidget); + + radius = widget.radius; + center = widget.center; + } + + Widget getFlutterMapCircleLayer() => CircleLayer( + circles: [ + if (center != null && radius != null) + CircleMarker( + point: center!, + radius: radius!, + useRadiusInMeter: true, + color: Colors.red.withOpacity(.3), + borderStrokeWidth: 5, + borderColor: Colors.red, + ), + ], + ); + + void updateZoom(final ScaleUpdateDetails scaleUpdateDetails) async { + final mapZoom = await (() async { + if (widget.appleMapController != null) { + return widget.appleMapController!.getZoomLevel(); + } else if (widget.flutterMapController != null) { + return widget.flutterMapController!.zoom; + } else { + return 0.0; + } + })() as double; + final difference = scaleUpdateDetails.scale - previousScale; + final multiplier = pow(2, 18 - mapZoom) * .2; + + final newRadius = max( + 50, + // Radius can only be changed if a center is set; + // meaning it will always be defined here + difference > 0 ? radius! + multiplier : radius! - multiplier, + ); + + if (widget.enableRealTimeRadiusUpdate) { + widget.onRadiusChange?.call(newRadius); + } else { + setState(() { + radius = newRadius; + }); + } + + previousScale = scaleUpdateDetails.scale; + } + + void leaveScaleMode() { + Vibration.vibrate(duration: 50); + + setState(() { + isInScaleMode = false; + }); + + if (!widget.enableRealTimeRadiusUpdate) { + widget.onRadiusChange?.call(radius!); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return GestureDetector( + onScaleUpdate: isInScaleMode ? updateZoom : null, + onTap: isInScaleMode ? leaveScaleMode : null, + child: Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + ignoring: isInScaleMode, + child: LocusFlutterMap( + flutterMapController: widget.flutterMapController, + appleMapController: widget.appleMapController, + initialZoom: 13.0, + onAppleMapCreated: widget.onAppleMapCreated, + onTap: (location) { + location = LatLng( + location.latitude, + location.longitude, + ); + + widget.onLocationChange?.call(location); + + if (radius == null) { + setState(() { + radius = INITIAL_RADIUS; + center = location; + }); + + widget.onRadiusChange?.call(INITIAL_RADIUS); + } + }, + onLongPress: (location) { + Vibration.vibrate(duration: 100); + + setState(() { + isInScaleMode = true; + }); + }, + flutterChildren: [ + if (isInScaleMode) + Shimmer.fromColors( + baseColor: Colors.red, + highlightColor: Colors.red.withOpacity(.2), + child: getFlutterMapCircleLayer(), + ) + else + getFlutterMapCircleLayer(), + CurrentLocationLayer( + followOnLocationUpdate: FollowOnLocationUpdate.once, + ) + ], + appleMapCircles: { + if (center != null && radius != null) + if (isInScaleMode) + apple_maps.Circle( + circleId: apple_maps.CircleId('radius-$radius-scale'), + center: toAppleMapsCoordinates(center!), + radius: radius!, + fillColor: Colors.orangeAccent.withOpacity(.35), + strokeColor: Colors.orangeAccent, + strokeWidth: 2, + ) + else + apple_maps.Circle( + circleId: apple_maps.CircleId('radius-$radius'), + center: toAppleMapsCoordinates(center!), + radius: radius!, + fillColor: Colors.red.withOpacity(.25), + strokeColor: Colors.red, + strokeWidth: 2, + ) + }, + ), + ), + ), + // If the map is deactivated via the `IgnorePointer` + // widget, we need some other widget to handle taps + // For this, we use an empty `Container` + if (isInScaleMode) ...[ + Positioned.fill( + child: Container( + color: Colors.transparent, + width: double.infinity, + height: double.infinity, + ), + ), + MapBanner( + child: Row( + children: [ + const Icon(Icons.pinch_rounded), + const SizedBox(width: MEDIUM_SPACE), + Flexible( + child: Text( + l10n.location_addAlarm_radiusBased_isInScaleMode, + style: const TextStyle( + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], + if (widget.center != null && radius != null) + Positioned( + bottom: LARGE_SPACE, + left: 0, + right: 0, + child: Text( + radius! > 10000 + ? l10n.location_addAlarm_radiusBased_radius_kilometers( + double.parse( + (radius! / 1000).toStringAsFixed(1), + ), + ) + : l10n.location_addAlarm_radiusBased_radius_meters( + radius!.round()), + textAlign: TextAlign.center, + ), + ), + ...widget.children, + ], + ), + ); + } +} diff --git a/lib/screens/view_alarm_screen_widgets/ProximityAlarmMetaDataSheet.dart b/lib/screens/view_alarm_screen_widgets/ProximityAlarmMetaDataSheet.dart new file mode 100644 index 00000000..19c2dc09 --- /dev/null +++ b/lib/screens/view_alarm_screen_widgets/ProximityAlarmMetaDataSheet.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:locus/constants/spacing.dart'; +import 'package:locus/services/location_alarm_service/ProximityLocationAlarm.dart'; +import 'package:locus/services/location_alarm_service/enums.dart'; +import 'package:locus/utils/theme.dart'; + +import '../../widgets/ModalSheet.dart'; +import '../../widgets/PlatformListTile.dart'; + +class ProximityAlarmMetaDataSheet extends StatelessWidget { + final double radius; + + const ProximityAlarmMetaDataSheet({ + required this.radius, + super.key, + }); + + void _createAlarm( + final BuildContext context, + final LocationRadiusBasedTriggerType type, + ) { + final alarm = ProximityLocationAlarm.create( + radius: radius, + type: type, + ); + + Navigator.pop( + context, + alarm, + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return ModalSheet( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.location_addAlarm_radiusBased_trigger_title, + style: getSubTitleTextStyle(context), + ), + const SizedBox(height: MEDIUM_SPACE), + PlatformListTile( + onTap: () { + _createAlarm(context, LocationRadiusBasedTriggerType.whenEnter); + }, + leading: const Icon(Icons.arrow_circle_right_rounded), + title: Text(l10n.location_addAlarm_radiusBased_trigger_whenEnter), + ), + PlatformListTile( + onTap: () { + _createAlarm(context, LocationRadiusBasedTriggerType.whenLeave); + }, + leading: const Icon(Icons.arrow_circle_left_rounded), + title: Text(l10n.location_addAlarm_radiusBased_trigger_whenLeave), + ), + ], + ), + ); + } +} diff --git a/lib/screens/view_alarm_screen_widgets/ProximityAlarmPreview.dart b/lib/screens/view_alarm_screen_widgets/ProximityAlarmPreview.dart new file mode 100644 index 00000000..2145c91d --- /dev/null +++ b/lib/screens/view_alarm_screen_widgets/ProximityAlarmPreview.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_location_marker/flutter_map_location_marker.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:locus/constants/spacing.dart'; +import 'package:locus/screens/locations_overview_screen_widgets/LocationFetchers.dart'; +import 'package:locus/services/current_location_service.dart'; +import 'package:locus/services/location_alarm_service/ProximityLocationAlarm.dart'; +import 'package:locus/services/location_alarm_service/index.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/location/get-fallback-location.dart'; +import 'package:locus/utils/map.dart'; +import 'package:locus/widgets/LocusFlutterMap.dart'; +import 'package:provider/provider.dart'; + +class ProximityAlarmPreview extends StatelessWidget { + final TaskView view; + final ProximityLocationAlarm alarm; + final VoidCallback onDelete; + + const ProximityAlarmPreview({ + required this.view, + required this.alarm, + required this.onDelete, + super.key, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final currentLocation = context.watch(); + final centerPosition = currentLocation.currentPosition == null + ? getFallbackLocation(context) + : LatLng( + currentLocation.currentPosition!.latitude, + currentLocation.currentPosition!.longitude, + ); + final locationFetchers = context.watch(); + final lastLocation = + locationFetchers.getLocations(view).lastOrNull?.asLatLng(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + PlatformListTile( + title: Text( + alarm.radius > 10000 + ? l10n.location_addAlarm_radiusBased_radius_kilometers( + double.parse( + (alarm.radius / 1000).toStringAsFixed(1), + ), + ) + : l10n.location_addAlarm_radiusBased_radius_meters( + alarm.radius.round()), + ), + leading: getIconForLocationRadiusBasedTrigger(context, alarm.type), + trailing: PlatformIconButton( + icon: Icon(context.platformIcons.delete), + onPressed: onDelete, + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(LARGE_SPACE), + child: SizedBox( + width: double.infinity, + height: 200, + child: IgnorePointer( + ignoring: true, + child: LocusFlutterMap( + flutterMapOptions: MapOptions( + center: centerPosition, + maxZoom: 18, + // create zoom based of radius + zoom: getZoomLevelForRadius(alarm.radius), + ), + flutterChildren: [ + CurrentLocationLayer( + positionStream: currentLocation.locationMarkerStream, + followOnLocationUpdate: FollowOnLocationUpdate.never, + ), + CircleLayer( + circles: [ + CircleMarker( + point: centerPosition, + useRadiusInMeter: true, + color: Colors.cyanAccent.withOpacity(0.3), + borderStrokeWidth: 5, + borderColor: Colors.cyanAccent, + radius: alarm.radius, + ), + if (lastLocation != null) ...[ + CircleMarker( + point: lastLocation, + useRadiusInMeter: false, + color: Colors.white, + radius: 7, + ), + CircleMarker( + point: lastLocation, + useRadiusInMeter: false, + color: Colors.cyan, + radius: 5, + ), + ], + ], + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/view_alarm_screen_widgets/ViewAlarmScreen.dart b/lib/screens/view_alarm_screen_widgets/ViewAlarmScreen.dart index 85fd5d34..b1411f48 100644 --- a/lib/screens/view_alarm_screen_widgets/ViewAlarmScreen.dart +++ b/lib/screens/view_alarm_screen_widgets/ViewAlarmScreen.dart @@ -7,19 +7,28 @@ import 'package:flutter_map/plugin_api.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:latlong2/latlong.dart'; import 'package:locus/constants/spacing.dart'; -import 'package:locus/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusRegionScreen.dart'; -import 'package:locus/services/location_alarm_service.dart'; +import 'package:locus/screens/view_alarm_screen_widgets/GeoLocationAlarmPreview.dart'; +import 'package:locus/screens/view_alarm_screen_widgets/ProximityAlarmPreview.dart'; +import 'package:locus/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusBasedScreen.dart'; +import 'package:locus/services/current_location_service.dart'; +import 'package:locus/services/location_alarm_service/LocationAlarmServiceBase.dart'; +import 'package:locus/services/location_alarm_service/ProximityLocationAlarm.dart'; +import 'package:locus/services/location_alarm_service/enums.dart'; +import 'package:locus/services/location_alarm_service/index.dart'; import 'package:locus/services/location_point_service.dart'; import 'package:locus/services/log_service.dart'; -import 'package:locus/services/view_service.dart'; +import 'package:locus/services/manager_service/helpers.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/navigation.dart'; import 'package:locus/utils/theme.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import 'package:locus/widgets/ModalSheet.dart'; +import 'package:locus/widgets/ModalSheetContent.dart'; import 'package:provider/provider.dart'; import '../../models/log.dart'; -import '../../utils/PageRoute.dart'; import '../../widgets/LocusFlutterMap.dart'; import '../../widgets/PlatformFlavorWidget.dart'; +import '../locations_overview_screen_widgets/LocationFetchers.dart'; class ViewAlarmScreen extends StatefulWidget { final TaskView view; @@ -37,24 +46,58 @@ class _ViewAlarmScreenState extends State { LocationPointService? lastLocation; void _addNewAlarm() async { - final logService = context.read(); - final viewService = context.read(); - final RadiusBasedRegionLocationAlarm? alarm = (await (() { - if (isCupertino(context)) { - return showCupertinoModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (_) => const ViewAlarmSelectRadiusRegionScreen(), - ); - } + final l10n = AppLocalizations.of(context); - return Navigator.of(context).push( - NativePageRoute( - context: context, - builder: (context) => const ViewAlarmSelectRadiusRegionScreen(), + final alarmType = await showPlatformModalSheet( + context: context, + material: MaterialModalSheetData( + backgroundColor: Colors.transparent, + isScrollControlled: true, + isDismissible: true, + ), + builder: (context) => ModalSheet( + child: ModalSheetContent( + icon: Icons.alarm_rounded, + title: l10n.location_addAlarm_selectType_title, + description: l10n.location_addAlarm_selectType_description, + children: [ + PlatformListTile( + title: Text(l10n.location_addAlarm_geo_title), + subtitle: Text(l10n.location_addAlarm_geo_description), + leading: const Icon(Icons.circle), + onTap: () { + Navigator.of(context).pop( + LocationAlarmType.geo, + ); + }, + ), + PlatformListTile( + title: Text(l10n.location_addAlarm_proximity_title), + subtitle: Text(l10n.location_addAlarm_proximity_description), + leading: const Icon(Icons.location_searching_rounded), + onTap: () { + Navigator.of(context).pop( + LocationAlarmType.proximity, + ); + }, + ), + ], ), - ); - })()) as RadiusBasedRegionLocationAlarm?; + ), + ); + + if (!mounted || alarmType == null) { + return; + } + + final logService = context.read(); + final viewService = context.read(); + final LocationAlarmServiceBase? alarm = await pushRoute( + context, + (context) => ViewAlarmSelectRadiusBasedScreen( + type: alarmType, + ), + ) as LocationAlarmServiceBase?; if (!mounted) { return; @@ -71,7 +114,7 @@ class _ViewAlarmScreenState extends State { Log.createAlarm( initiator: LogInitiator.user, id: alarm.id, - alarmType: LocationAlarmType.radiusBasedRegion, + alarmType: LocationAlarmType.geo, viewID: widget.view.id, viewName: widget.view.name, ), @@ -116,20 +159,6 @@ class _ViewAlarmScreenState extends State { super.initState(); widget.view.addListener(updateView); - - widget.view.getLocations( - onLocationFetched: (final location) { - if (!mounted) { - return; - } - - setState(() { - lastLocation = location; - }); - }, - onEnd: () {}, - limit: 1, - ); } @override @@ -143,7 +172,7 @@ class _ViewAlarmScreenState extends State { setState(() {}); } - Widget buildMap(final RadiusBasedRegionLocationAlarm alarm) { + Widget buildMap(final GeoLocationAlarm alarm) { // Apple Maps doesn't seem to be working with multiple maps // see https://github.com/LuisThein/apple_maps_flutter/issues/44 /* @@ -184,20 +213,23 @@ class _ViewAlarmScreenState extends State { ); } */ + final locationFetchers = context.watch(); + final lastLocation = + locationFetchers.getLocations(widget.view).lastOrNull?.asLatLng(); return LocusFlutterMap( - options: MapOptions( + flutterMapOptions: MapOptions( center: alarm.center, maxZoom: 18, // create zoom based of radius zoom: 18 - log(alarm.radius / 35) / log(2), ), - children: [ + flutterChildren: [ CircleLayer( circles: [ if (lastLocation != null) CircleMarker( - point: LatLng(lastLocation!.latitude, lastLocation!.longitude), + point: LatLng(lastLocation.latitude, lastLocation.longitude), radius: 5, color: Colors.blue, ), @@ -215,6 +247,130 @@ class _ViewAlarmScreenState extends State { ); } + VoidCallback _deleteAlarm(final LocationAlarmServiceBase alarm) { + return () async { + final l10n = AppLocalizations.of(context); + final shouldDelete = await showPlatformDialog( + context: context, + builder: (context) => PlatformAlertDialog( + material: (context, __) => MaterialAlertDialogData( + icon: const Icon(Icons.delete_forever_rounded), + ), + title: Text(l10n.location_removeAlarm_title), + content: Text(l10n.location_removeAlarm_description), + actions: createCancellableDialogActions( + context, + [ + PlatformDialogAction( + material: (context, _) => MaterialDialogActionData( + icon: const Icon(Icons.delete_forever_rounded), + ), + cupertino: (context, _) => CupertinoDialogActionData( + isDestructiveAction: true, + ), + child: Text(l10n.location_removeAlarm_confirm), + onPressed: () => Navigator.pop(context, true), + ), + ], + ), + ), + ); + + if (!mounted || shouldDelete != true) { + return; + } + + final viewService = context.read(); + final logService = context.read(); + + widget.view.removeAlarm(alarm); + await viewService.update(widget.view); + + await logService.addLog( + Log.deleteAlarm( + initiator: LogInitiator.user, + viewID: widget.view.id, + viewName: widget.view.name, + ), + ); + }; + } + + Widget getList() { + final l10n = AppLocalizations.of(context); + + return SingleChildScrollView( + child: Column( + children: [ + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: widget.view.alarms.length, + itemBuilder: (context, index) { + final alarm = widget.view.alarms[index]; + final handleDelete = _deleteAlarm(alarm); + + final child = (() { + switch (alarm.IDENTIFIER) { + case LocationAlarmType.geo: + return GeoLocationAlarmPreview( + view: widget.view, + alarm: alarm as GeoLocationAlarm, + onDelete: handleDelete, + ); + case LocationAlarmType.proximity: + return ProximityAlarmPreview( + view: widget.view, + alarm: alarm as ProximityLocationAlarm, + onDelete: handleDelete, + ); + } + })(); + + return Padding( + padding: const EdgeInsets.only( + bottom: MEDIUM_SPACE, + ), + child: child, + ); + }, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: MEDIUM_SPACE), + child: PlatformElevatedButton( + onPressed: _addNewAlarm, + material: (_, __) => MaterialElevatedButtonData( + icon: const Icon(Icons.add), + ), + child: Text(l10n.location_manageAlarms_addNewAlarm_actionLabel), + ), + ), + GestureDetector( + onTap: () async { + final l10n = AppLocalizations.of(context); + final views = context.read(); + final currentLocation = context.read(); + + checkViewAlarms( + l10n: l10n, + viewService: views, + userLocation: await LocationPointService.fromPosition( + currentLocation.currentPosition!), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: MEDIUM_SPACE), + child: Text( + l10n.location_manageAlarms_lastCheck_description( + widget.view.lastAlarmCheck), + ), + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -227,90 +383,7 @@ class _ViewAlarmScreenState extends State { child: Padding( padding: const EdgeInsets.symmetric(horizontal: MEDIUM_SPACE), child: Center( - child: widget.view.alarms.isEmpty - ? getEmptyState() - : SingleChildScrollView( - child: Column( - children: [ - ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: widget.view.alarms.length, - itemBuilder: (context, index) { - final RadiusBasedRegionLocationAlarm alarm = - widget.view.alarms[index] - as RadiusBasedRegionLocationAlarm; - - return Padding( - padding: const EdgeInsets.only( - bottom: MEDIUM_SPACE, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - PlatformListTile( - title: Text(alarm.zoneName), - leading: alarm.getIcon(context), - trailing: PlatformIconButton( - icon: Icon(context.platformIcons.delete), - onPressed: () async { - final viewService = - context.read(); - final logService = - context.read(); - - widget.view.removeAlarm(alarm); - await viewService.update(widget.view); - - await logService.addLog( - Log.deleteAlarm( - initiator: LogInitiator.user, - viewID: widget.view.id, - viewName: widget.view.name, - ), - ); - }, - ), - ), - ClipRRect( - borderRadius: - BorderRadius.circular(LARGE_SPACE), - child: SizedBox( - width: double.infinity, - height: 200, - child: IgnorePointer( - ignoring: true, - child: buildMap(alarm), - ), - ), - ), - ], - ), - ); - }, - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: MEDIUM_SPACE), - child: PlatformElevatedButton( - onPressed: _addNewAlarm, - material: (_, __) => MaterialElevatedButtonData( - icon: const Icon(Icons.add), - ), - child: Text(l10n - .location_manageAlarms_addNewAlarm_actionLabel), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: MEDIUM_SPACE), - child: Text( - l10n.location_manageAlarms_lastCheck_description( - widget.view.lastAlarmCheck)), - ), - ], - ), - ), + child: widget.view.alarms.isEmpty ? getEmptyState() : getList(), ), ), ), diff --git a/lib/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusBasedScreen.dart b/lib/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusBasedScreen.dart new file mode 100644 index 00000000..6e387cf3 --- /dev/null +++ b/lib/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusBasedScreen.dart @@ -0,0 +1,350 @@ +import 'dart:math'; + +import 'package:apple_maps_flutter/apple_maps_flutter.dart' as apple_maps; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:locus/constants/spacing.dart'; +import 'package:locus/screens/view_alarm_screen_widgets/GeoAlarmMetaDataSheet.dart'; +import 'package:locus/screens/view_alarm_screen_widgets/LocationRadiusSelectorMap.dart'; +import 'package:locus/screens/view_alarm_screen_widgets/ProximityAlarmMetaDataSheet.dart'; +import 'package:locus/services/current_location_service.dart'; +import 'package:locus/services/location_alarm_service/LocationAlarmServiceBase.dart'; +import 'package:locus/services/location_alarm_service/enums.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/utils/helper_sheet.dart'; +import 'package:locus/utils/location/index.dart'; +import 'package:locus/utils/permissions/has-granted.dart'; +import 'package:locus/utils/theme.dart'; +import 'package:locus/widgets/GoToMyLocationMapAction.dart'; +import 'package:locus/widgets/MapActionsContainer.dart'; +import 'package:locus/widgets/RequestNotificationPermissionMixin.dart'; +import 'package:provider/provider.dart'; + +class ViewAlarmSelectRadiusBasedScreen extends StatefulWidget { + final LocationAlarmType type; + + const ViewAlarmSelectRadiusBasedScreen({ + super.key, + required this.type, + }); + + @override + State createState() => + _ViewAlarmSelectRadiusBasedScreenState(); +} + +class _ViewAlarmSelectRadiusBasedScreenState + extends State + with RequestNotificationPermissionMixin { + MapController? flutterMapController; + apple_maps.AppleMapController? appleMapController; + + LatLng? alarmCenter; + + bool isInScaleMode = false; + double radius = 50.0; + double previousScale = 1; + Stream? _positionStream; + + bool isGoingToCurrentPosition = false; + + bool _hasSetInitialPosition = false; + + late CurrentLocationService _currentLocationService; + + @override + void initState() { + super.initState(); + + final settings = context.read(); + _currentLocationService = context.read(); + + if (settings.mapProvider == MapProvider.openStreetMap) { + flutterMapController = MapController(); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + goToCurrentPosition(); + + _setPositionFromCurrentLocationService(updateAlarmCenter: true); + _showHelperSheetIfRequired(); + }); + + _currentLocationService.addListener(_setPositionFromCurrentLocationService); + } + + @override + void dispose() { + flutterMapController?.dispose(); + _positionStream?.drain(); + + _currentLocationService + .removeListener(_setPositionFromCurrentLocationService); + + super.dispose(); + } + + void _setPositionFromCurrentLocationService({ + final updateAlarmCenter = false, + }) { + if (_currentLocationService.currentPosition == null) { + return; + } + + _animateToPosition(_currentLocationService.currentPosition!); + + if (updateAlarmCenter || widget.type == LocationAlarmType.proximity) { + setState(() { + alarmCenter = LatLng( + _currentLocationService.currentPosition!.latitude, + _currentLocationService.currentPosition!.longitude, + ); + }); + } + } + + void _showHelperSheetIfRequired() async { + final settings = context.read(); + + if (!settings.hasSeenHelperSheet(HelperSheet.radiusBasedAlarms)) { + await Future.delayed(const Duration(seconds: 1)); + + if (!mounted) { + return; + } + + showHelp(); + } + } + + void _animateToPosition(final Position position) async { + final zoom = _hasSetInitialPosition + ? (16 - log(position.accuracy / 200) / log(2)).toDouble() + : flutterMapController?.zoom ?? + (await appleMapController?.getZoomLevel()) ?? + 16.0; + + flutterMapController?.move( + LatLng(position.latitude, position.longitude), + zoom, + ); + appleMapController?.moveCamera( + apple_maps.CameraUpdate.newLatLng( + apple_maps.LatLng(position.latitude, position.longitude), + ), + ); + + if (!_hasSetInitialPosition) { + _hasSetInitialPosition = true; + setState(() { + alarmCenter = LatLng(position.latitude, position.longitude); + }); + } + } + + void showHelp() { + final l10n = AppLocalizations.of(context); + + showHelperSheet( + context: context, + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.location_addAlarm_radiusBased_help_description), + const SizedBox(height: MEDIUM_SPACE), + if (widget.type == LocationAlarmType.geo) ...[ + Row( + children: [ + const Icon(Icons.touch_app_rounded), + const SizedBox(width: MEDIUM_SPACE), + Flexible( + child: Text( + l10n.location_addAlarm_geo_help_tapDescription, + ), + ), + ], + ), + ], + const SizedBox(height: MEDIUM_SPACE), + Row( + children: [ + const Icon(Icons.pinch_rounded), + const SizedBox(width: MEDIUM_SPACE), + Flexible( + child: Text( + l10n.location_addAlarm_radiusBased_help_pinchDescription, + ), + ), + ], + ), + ], + ), + title: l10n.location_addAlarm_radiusBased_help_title, + sheetName: HelperSheet.radiusBasedAlarms, + ); + } + + void goToCurrentPosition() async { + if (!(await hasGrantedLocationPermission())) { + return; + } + + setState(() { + isGoingToCurrentPosition = true; + }); + + _positionStream = getLastAndCurrentPosition() + ..listen((position) async { + final currentLocation = context.read(); + + currentLocation.updateCurrentPosition(position); + + setState(() { + isGoingToCurrentPosition = false; + }); + + _animateToPosition(position); + }); + } + + Future _selectRegion() async { + LocationAlarmServiceBase? alarm; + + switch (widget.type) { + case LocationAlarmType.geo: + alarm = await showPlatformModalSheet( + context: context, + material: MaterialModalSheetData( + backgroundColor: Colors.transparent, + isDismissible: true, + isScrollControlled: true, + ), + builder: (_) => GeoAlarmMetaDataSheet( + center: alarmCenter!, + radius: radius.toDouble(), + ), + ); + break; + case LocationAlarmType.proximity: + alarm = await showPlatformModalSheet( + context: context, + material: MaterialModalSheetData( + backgroundColor: Colors.transparent, + isDismissible: true, + isScrollControlled: true, + ), + builder: (_) => ProximityAlarmMetaDataSheet( + radius: radius, + ), + ); + } + + if (alarm == null) { + return; + } + + final hasGrantedNotificationAccess = + await showNotificationPermissionDialog(); + + if (!hasGrantedNotificationAccess || !mounted) { + return; + } + + Navigator.pop(context, alarm); + } + + Widget buildMapActions() { + return MapActionsContainer( + children: [ + GoToMyLocationMapAction( + onGoToMyLocation: goToCurrentPosition, + animate: isGoingToCurrentPosition, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + final TYPE_TITLE_MAP = { + LocationAlarmType.geo: l10n.location_addAlarm_geo_title, + LocationAlarmType.proximity: l10n.location_addAlarm_proximity_title, + }; + + return PlatformScaffold( + material: (_, __) => MaterialScaffoldData( + resizeToAvoidBottomInset: false, + ), + appBar: PlatformAppBar( + title: Text(TYPE_TITLE_MAP[widget.type]!), + trailingActions: [ + PlatformIconButton( + cupertino: (_, __) => CupertinoIconButtonData( + padding: EdgeInsets.zero, + ), + icon: Icon(context.platformIcons.help), + onPressed: showHelp, + ), + ], + cupertino: (_, __) => CupertinoNavigationBarData( + backgroundColor: isInScaleMode + ? null + : getCupertinoAppBarColorForMapScreen(context), + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 10, + child: LocationRadiusSelectorMap( + center: alarmCenter, + radius: radius, + flutterMapController: flutterMapController, + appleMapController: appleMapController, + onLocationChange: (location) { + // Proximity does not need a center + if (widget.type == LocationAlarmType.proximity) { + return; + } + + setState(() { + alarmCenter = location; + }); + }, + onAppleMapCreated: (controller) { + appleMapController = controller; + }, + onRadiusChange: (newRadius) { + setState(() { + radius = newRadius; + }); + }, + children: [ + buildMapActions(), + ], + ), + ), + Expanded( + child: TextButton.icon( + icon: Icon(context.platformIcons.checkMark), + onPressed: _selectRegion, + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + label: Text(l10n.location_addAlarm_radiusBased_addLabel), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusRegionScreen.dart b/lib/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusRegionScreen.dart deleted file mode 100644 index cc0bd935..00000000 --- a/lib/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusRegionScreen.dart +++ /dev/null @@ -1,401 +0,0 @@ -import 'dart:math' as math; - -import 'package:apple_maps_flutter/apple_maps_flutter.dart' as apple_maps; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:locus/constants/spacing.dart'; -import 'package:locus/constants/values.dart'; -import 'package:locus/screens/view_alarm_screen_widgets/RadiusRegionMetaDataSheet.dart'; -import 'package:locus/services/location_alarm_service.dart'; -import 'package:locus/services/settings_service.dart'; -import 'package:locus/utils/helper_sheet.dart'; -import 'package:locus/utils/location/get-fallback-location.dart'; -import 'package:locus/utils/location/index.dart'; -import 'package:locus/utils/permissions/has-granted.dart'; -import 'package:locus/utils/permissions/request.dart'; -import 'package:locus/utils/theme.dart'; -import 'package:locus/widgets/LocationsMap.dart'; -import 'package:locus/widgets/RequestNotificationPermissionMixin.dart'; -import 'package:provider/provider.dart'; -import 'package:shimmer/shimmer.dart'; -import 'package:vibration/vibration.dart'; - -import '../../widgets/LocusFlutterMap.dart'; -import '../../widgets/MapBanner.dart'; - -class ViewAlarmSelectRadiusRegionScreen extends StatefulWidget { - const ViewAlarmSelectRadiusRegionScreen({ - super.key, - }); - - @override - State createState() => - _ViewAlarmSelectRadiusRegionScreenState(); -} - -class _ViewAlarmSelectRadiusRegionScreenState - extends State - with RequestNotificationPermissionMixin { - MapController? flutterMapController; - apple_maps.AppleMapController? appleMapController; - LatLng? alarmCenter; - bool isInScaleMode = false; - double radius = 100; - double previousScale = 1; - Stream? _positionStream; - - @override - void initState() { - super.initState(); - - final settings = context.read(); - if (settings.mapProvider == MapProvider.openStreetMap) { - flutterMapController = MapController(); - } - - WidgetsBinding.instance.addPostFrameCallback((_) async { - goToCurrentPosition(); - - final settings = context.read(); - - if (!settings.hasSeenHelperSheet(HelperSheet.radiusBasedAlarms)) { - await Future.delayed(const Duration(seconds: 1)); - - if (!mounted) { - return; - } - - showHelp(); - } - }); - } - - void showHelp() { - final l10n = AppLocalizations.of(context); - - showHelperSheet( - context: context, - builder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.location_addAlarm_radiusBased_help_description), - const SizedBox(height: MEDIUM_SPACE), - Row( - children: [ - const Icon(Icons.touch_app_rounded), - const SizedBox(width: MEDIUM_SPACE), - Flexible( - child: Text( - l10n.location_addAlarm_radiusBased_help_tapDescription), - ), - ], - ), - const SizedBox(height: MEDIUM_SPACE), - Row( - children: [ - const Icon(Icons.pinch_rounded), - const SizedBox(width: MEDIUM_SPACE), - Flexible( - child: Text( - l10n.location_addAlarm_radiusBased_help_pinchDescription), - ), - ], - ), - ], - ), - title: l10n.location_addAlarm_radiusBased_help_title, - sheetName: HelperSheet.radiusBasedAlarms, - ); - } - - void goToCurrentPosition() async { - if (!(await hasGrantedLocationPermission())) { - return; - } - - _positionStream = getLastAndCurrentPosition() - ..listen((position) { - flutterMapController?.move( - LatLng(position.latitude, position.longitude), - 13, - ); - appleMapController?.moveCamera( - apple_maps.CameraUpdate.newLatLng( - apple_maps.LatLng(position.latitude, position.longitude), - ), - ); - }); - } - - @override - void dispose() { - flutterMapController?.dispose(); - _positionStream?.drain(); - - super.dispose(); - } - - CircleLayer getFlutterMapCircleLayer() => CircleLayer( - circles: [ - CircleMarker( - point: alarmCenter!, - radius: radius, - useRadiusInMeter: true, - color: Colors.red.withOpacity(.3), - borderStrokeWidth: 5, - borderColor: Colors.red, - ), - ], - ); - - Future _selectRegion() async { - final RadiusBasedRegionLocationAlarm? alarm = await showPlatformModalSheet( - context: context, - material: MaterialModalSheetData( - backgroundColor: Colors.transparent, - isDismissible: true, - isScrollControlled: true, - ), - builder: (_) => RadiusRegionMetaDataSheet( - center: alarmCenter!, - radius: radius, - ), - ); - - final hasGrantedNotificationAccess = - await showNotificationPermissionDialog(); - - if (!hasGrantedNotificationAccess) { - return; - } - - if (!mounted) { - return; - } - - if (alarm != null) { - Navigator.pop(context, alarm); - } - } - - void updateZoom(final ScaleUpdateDetails scaleUpdateDetails) async { - final mapZoom = await (() async { - if (appleMapController != null) { - return appleMapController!.getZoomLevel(); - } else if (flutterMapController != null) { - return flutterMapController!.zoom; - } else { - return 0.0; - } - })() as double; - final difference = scaleUpdateDetails.scale - previousScale; - final multiplier = math.pow(2, 18 - mapZoom) * .2; - - final newRadius = math.max( - 50, - difference > 0 ? radius + multiplier : radius - multiplier, - ); - - setState(() { - radius = newRadius; - }); - - previousScale = scaleUpdateDetails.scale; - } - - Widget buildMap() { - final settings = context.read(); - - if (settings.mapProvider == MapProvider.apple) { - return apple_maps.AppleMap( - initialCameraPosition: apple_maps.CameraPosition( - target: toAppleMapsCoordinates(getFallbackLocation(context)), - zoom: FALLBACK_LOCATION_ZOOM_LEVEL, - ), - onMapCreated: (controller) { - appleMapController = controller; - }, - onLongPress: (_) { - if (alarmCenter == null) { - return; - } - - Vibration.vibrate(duration: 100); - - setState(() { - isInScaleMode = true; - }); - }, - myLocationEnabled: true, - onTap: (tapPosition) { - setState(() { - alarmCenter = LatLng( - tapPosition.latitude, - tapPosition.longitude, - ); - }); - }, - circles: { - if (alarmCenter != null) - apple_maps.Circle( - circleId: apple_maps.CircleId('alarm-${radius.round()}'), - center: apple_maps.LatLng( - alarmCenter!.latitude, - alarmCenter!.longitude, - ), - radius: radius, - fillColor: Colors.red.withOpacity(.3), - strokeWidth: 5, - consumeTapEvents: false, - ), - }, - ); - } - - return LocusFlutterMap( - mapController: flutterMapController, - options: MapOptions( - onLongPress: (_, __) { - if (alarmCenter == null) { - return; - } - - Vibration.vibrate(duration: 100); - - setState(() { - isInScaleMode = true; - }); - }, - center: getFallbackLocation(context), - zoom: FALLBACK_LOCATION_ZOOM_LEVEL, - onTap: (tapPosition, location) { - setState(() { - alarmCenter = location; - }); - }, - maxZoom: 18, - ), - children: [ - if (alarmCenter != null) - if (isInScaleMode) - Shimmer.fromColors( - baseColor: Colors.red, - highlightColor: Colors.red.withOpacity(.2), - child: getFlutterMapCircleLayer(), - ) - else - getFlutterMapCircleLayer(), - ], - ); - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - - return PlatformScaffold( - material: (_, __) => MaterialScaffoldData( - resizeToAvoidBottomInset: false, - ), - appBar: PlatformAppBar( - title: Text(l10n.location_addAlarm_radiusBased_title), - trailingActions: [ - PlatformIconButton( - cupertino: (_, __) => CupertinoIconButtonData( - padding: EdgeInsets.zero, - ), - icon: const Icon(Icons.my_location_rounded), - onPressed: () async { - final hasGrantedLocation = await requestBasicLocationPermission(); - - if (hasGrantedLocation) { - goToCurrentPosition(); - } - }, - ), - PlatformIconButton( - cupertino: (_, __) => CupertinoIconButtonData( - padding: EdgeInsets.zero, - ), - icon: Icon(context.platformIcons.help), - onPressed: showHelp, - ), - ], - cupertino: (_, __) => CupertinoNavigationBarData( - backgroundColor: isInScaleMode - ? null - : getCupertinoAppBarColorForMapScreen(context), - ), - ), - body: GestureDetector( - onScaleUpdate: isInScaleMode ? updateZoom : null, - onTap: isInScaleMode - ? () { - Vibration.vibrate(duration: 50); - setState(() { - isInScaleMode = false; - }); - } - : null, - // We need a `Stack` to disable the map, but also need to show a container to detect the long press again - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 10, - child: Stack( - children: [ - Positioned.fill( - child: IgnorePointer( - ignoring: isInScaleMode, - child: buildMap(), - ), - ), - if (isInScaleMode) ...[ - Positioned.fill( - child: Container( - color: Colors.transparent, - ), - ), - MapBanner( - child: Row( - children: [ - const Icon(Icons.pinch_rounded), - const SizedBox(width: MEDIUM_SPACE), - Flexible( - child: Text( - l10n.location_addAlarm_radiusBased_isInScaleMode, - style: const TextStyle( - color: Colors.white, - ), - ), - ), - ], - ), - ), - ] - ], - ), - ), - Expanded( - child: TextButton.icon( - icon: Icon(context.platformIcons.checkMark), - onPressed: alarmCenter == null ? null : _selectRegion, - style: TextButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - ), - label: Text(l10n.location_addAlarm_radiusBased_addLabel), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/view_details_screen_widgets/LocationPointsList.dart b/lib/screens/view_details_screen_widgets/LocationPointsList.dart new file mode 100644 index 00000000..37699fb9 --- /dev/null +++ b/lib/screens/view_details_screen_widgets/LocationPointsList.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:locus/screens/locations_overview_screen_widgets/LocationFetchers.dart'; +import 'package:locus/screens/task_detail_screen_widgets/LocationDetails.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:provider/provider.dart'; + +class LocationPointsList extends StatefulWidget { + final TaskView view; + + const LocationPointsList({ + super.key, + required this.view, + }); + + @override + State createState() => _LocationPointsListState(); +} + +class _LocationPointsListState extends State { + final ScrollController controller = ScrollController(); + late final LocationFetchers locationFetchers; + + @override + void initState() { + super.initState(); + + locationFetchers = context.read(); + final fetcher = locationFetchers.findFetcher(widget.view)!; + + fetcher.addListener(_rebuild); + controller.addListener(() { + if (fetcher.hasFetchedAllLocations) { + return; + } + + if (controller.position.atEdge) { + final isTop = controller.position.pixels == 0; + + if (!isTop) { + fetcher.fetchMoreLocations(); + } + } + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!fetcher.hasFetchedAllLocations) { + fetcher.fetchMoreLocations(); + } + }); + } + + @override + void dispose() { + final fetcher = locationFetchers.findFetcher(widget.view)!; + + fetcher.removeListener(_rebuild); + + super.dispose(); + } + + void _rebuild() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final locationFetchers = context.watch(); + final fetcher = locationFetchers.findFetcher(widget.view)!; + final locations = fetcher.isLoading + ? fetcher.sortedLocations + : fetcher.locations.toList(); + + return ListView.builder( + shrinkWrap: true, + controller: controller, + itemCount: locations.length + (fetcher.isLoading ? 1 : 0), + itemBuilder: (_, index) { + if (index == locations.length) { + return const Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ), + ); + } + + return LocationDetails( + location: locations[index], + isPreview: false, + ); + }, + ); + } +} diff --git a/lib/screens/view_details_screen_widgets/ViewLocationPointsScreen.dart b/lib/screens/view_details_screen_widgets/ViewLocationPointsScreen.dart deleted file mode 100644 index 40a68100..00000000 --- a/lib/screens/view_details_screen_widgets/ViewLocationPointsScreen.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:locus/constants/spacing.dart'; -import 'package:locus/services/location_fetch_controller.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -import '../task_detail_screen_widgets/LocationDetails.dart'; - -class ViewLocationPointsScreen extends StatefulWidget { - final LocationFetcher locationFetcher; - - const ViewLocationPointsScreen({ - required this.locationFetcher, - super.key, - }); - - @override - State createState() => _ViewLocationPointsScreenState(); -} - -class _ViewLocationPointsScreenState extends State { - final ScrollController _controller = ScrollController(); - - @override - void initState() { - super.initState(); - - widget.locationFetcher.addListener(updateView); - - _controller.addListener(() { - print(widget.locationFetcher.canFetchMore); - if (!widget.locationFetcher.canFetchMore) { - return; - } - - if (_controller.position.atEdge) { - final isTop = _controller.position.pixels == 0; - - if (!isTop) { - widget.locationFetcher.fetchMore(onEnd: () { - setState(() {}); - }); - } - } - }); - } - - updateView() { - setState(() {}); - } - - @override - void dispose() { - widget.locationFetcher.removeListener(updateView); - _controller.dispose(); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - - return PlatformScaffold( - appBar: PlatformAppBar( - title: Text(l10n.locationPointsScreen_title), - material: (_, __) => MaterialAppBarData( - centerTitle: true, - ), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(MEDIUM_SPACE), - child: ListView.builder( - shrinkWrap: true, - controller: _controller, - itemCount: widget.locationFetcher.controller.locations.length + (widget.locationFetcher.isLoading ? 1 : 0), - itemBuilder: (_, index) { - if (index == widget.locationFetcher.controller.locations.length) { - return const Center( - child: SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(), - ), - ); - } - - return LocationDetails( - location: widget.locationFetcher.controller.locations[index], - isPreview: false, - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/services/current_location_service.dart b/lib/services/current_location_service.dart new file mode 100644 index 00000000..065db0b5 --- /dev/null +++ b/lib/services/current_location_service.dart @@ -0,0 +1,34 @@ +// Helper class to get the current location of the user +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_map_location_marker/flutter_map_location_marker.dart'; +import 'package:geolocator/geolocator.dart'; + +class CurrentLocationService extends ChangeNotifier { + final StreamController _positionStreamController = + StreamController.broadcast(); + final StreamController + _locationMarkerStreamController = StreamController.broadcast(); + Position? currentPosition; + + Stream get stream => _positionStreamController.stream; + + Stream get locationMarkerStream => + _locationMarkerStreamController.stream; + + Future updateCurrentPosition(final Position newPosition) async { + currentPosition = newPosition; + + _positionStreamController.add(newPosition); + _locationMarkerStreamController.add( + LocationMarkerPosition( + latitude: newPosition.latitude, + longitude: newPosition.longitude, + accuracy: newPosition.accuracy, + ), + ); + + notifyListeners(); + } +} diff --git a/lib/services/location_alarm_service.dart b/lib/services/location_alarm_service.dart deleted file mode 100644 index 6191bfde..00000000 --- a/lib/services/location_alarm_service.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:uuid/uuid.dart'; -import 'location_point_service.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -const uuid = Uuid(); - -enum LocationAlarmTriggerType { - yes, - no, - maybe, -} - -enum LocationAlarmType { - radiusBasedRegion, -} - -abstract class LocationAlarmServiceBase { - final String id; - - LocationAlarmType get IDENTIFIER; - - String createNotificationTitle(final AppLocalizations l10n, final String viewName); - - Map toJSON(); - - // Checks if the alarm should be triggered - // This function will be called each time the background fetch is updated and there are new locations - LocationAlarmTriggerType check(final LocationPointService previousLocation, final LocationPointService nextLocation); - - String getStorageKey() => "location_alarm_service:$IDENTIFIER:$id"; - - const LocationAlarmServiceBase(this.id); -} - -enum RadiusBasedRegionLocationAlarmType { - whenEnter, - whenLeave, -} - -class RadiusBasedRegionLocationAlarm extends LocationAlarmServiceBase { - final String zoneName; - final LatLng center; - - // Radius in meters - final double radius; - final RadiusBasedRegionLocationAlarmType type; - - const RadiusBasedRegionLocationAlarm({ - required this.center, - required this.radius, - required this.type, - required this.zoneName, - required String id, - }) : super(id); - - @override - LocationAlarmType get IDENTIFIER => LocationAlarmType.radiusBasedRegion; - - factory RadiusBasedRegionLocationAlarm.fromJSON(final Map data) => RadiusBasedRegionLocationAlarm( - center: LatLng.fromJson(data["center"]), - radius: data["radius"], - type: RadiusBasedRegionLocationAlarmType.values[data["alarmType"]], - zoneName: data["zoneName"], - id: data["id"], - ); - - factory RadiusBasedRegionLocationAlarm.create({ - required final LatLng center, - required final double radius, - required final RadiusBasedRegionLocationAlarmType type, - required final String zoneName, - }) => - RadiusBasedRegionLocationAlarm( - center: center, - radius: radius, - type: type, - zoneName: zoneName, - id: uuid.v4(), - ); - - @override - Map toJSON() { - return { - "_IDENTIFIER": IDENTIFIER.name, - "center": center.toJson(), - "radius": radius, - "zoneName": zoneName, - "alarmType": type.index, - "id": id, - }; - } - - @override - String createNotificationTitle(final l10n, final viewName) { - switch (type) { - case RadiusBasedRegionLocationAlarmType.whenEnter: - return l10n.locationAlarm_radiusBasedRegion_notificationTitle_whenEnter(viewName, zoneName); - case RadiusBasedRegionLocationAlarmType.whenLeave: - return l10n.locationAlarm_radiusBasedRegion_notificationTitle_whenLeave(viewName, zoneName); - } - } - - // Checks if a given location was inside. If not, it must be outside - LocationAlarmTriggerType _wasInside(final LocationPointService location) { - final fullDistance = Geolocator.distanceBetween( - location.latitude, - location.longitude, - center.latitude, - center.longitude, - ); - - if (fullDistance < radius && location.accuracy < radius) { - return LocationAlarmTriggerType.yes; - } - - if (fullDistance - location.accuracy - radius > 0) { - return LocationAlarmTriggerType.no; - } - - return LocationAlarmTriggerType.maybe; - } - - @override - LocationAlarmTriggerType check(final previousLocation, final nextLocation) { - final previousInside = _wasInside(previousLocation); - final nextInside = _wasInside(nextLocation); - - switch (type) { - case RadiusBasedRegionLocationAlarmType.whenEnter: - if (previousInside == LocationAlarmTriggerType.no && nextInside == LocationAlarmTriggerType.yes) { - return LocationAlarmTriggerType.yes; - } - - if (previousInside == LocationAlarmTriggerType.maybe && nextInside == LocationAlarmTriggerType.yes) { - return LocationAlarmTriggerType.yes; - } - - if (previousInside == LocationAlarmTriggerType.no && nextInside == LocationAlarmTriggerType.maybe) { - return LocationAlarmTriggerType.maybe; - } - - if (previousInside == LocationAlarmTriggerType.maybe && nextInside == LocationAlarmTriggerType.maybe) { - return LocationAlarmTriggerType.maybe; - } - break; - case RadiusBasedRegionLocationAlarmType.whenLeave: - if (previousInside == LocationAlarmTriggerType.yes && nextInside == LocationAlarmTriggerType.no) { - return LocationAlarmTriggerType.yes; - } - - if (previousInside == LocationAlarmTriggerType.maybe && nextInside == LocationAlarmTriggerType.no) { - return LocationAlarmTriggerType.yes; - } - - if (previousInside == LocationAlarmTriggerType.yes && nextInside == LocationAlarmTriggerType.maybe) { - return LocationAlarmTriggerType.maybe; - } - - if (previousInside == LocationAlarmTriggerType.maybe && nextInside == LocationAlarmTriggerType.maybe) { - return LocationAlarmTriggerType.maybe; - } - break; - } - - return LocationAlarmTriggerType.no; - } - - Icon getIcon(final BuildContext context) { - switch (type) { - case RadiusBasedRegionLocationAlarmType.whenEnter: - return const Icon(Icons.arrow_circle_right_rounded); - case RadiusBasedRegionLocationAlarmType.whenLeave: - return const Icon(Icons.arrow_circle_left_rounded); - } - } -} diff --git a/lib/services/location_alarm_service/GeoLocationAlarm.dart b/lib/services/location_alarm_service/GeoLocationAlarm.dart new file mode 100644 index 00000000..5c660fe2 --- /dev/null +++ b/lib/services/location_alarm_service/GeoLocationAlarm.dart @@ -0,0 +1,159 @@ +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:uuid/uuid.dart'; + +import 'LocationAlarmServiceBase.dart'; +import 'enums.dart'; + +const uuid = Uuid(); + +class GeoLocationAlarm extends LocationAlarmServiceBase { + final String zoneName; + final LatLng center; + + // Radius in meters + final double radius; + final LocationRadiusBasedTriggerType type; + + const GeoLocationAlarm({ + required this.center, + required this.radius, + required this.type, + required this.zoneName, + required String id, + }) : super(id); + + @override + LocationAlarmType get IDENTIFIER => LocationAlarmType.geo; + + factory GeoLocationAlarm.fromJSON( + final Map data, + ) => + GeoLocationAlarm( + center: LatLng.fromJson(data["center"]), + radius: data["radius"], + type: LocationRadiusBasedTriggerType.values[data["alarmType"]], + zoneName: data["zoneName"], + id: data["id"], + ); + + factory GeoLocationAlarm.create({ + required final LatLng center, + required final double radius, + required final LocationRadiusBasedTriggerType type, + required final String zoneName, + }) => + GeoLocationAlarm( + center: center, + radius: radius, + type: type, + zoneName: zoneName, + id: uuid.v4(), + ); + + @override + Map toJSON() { + return { + "_IDENTIFIER": IDENTIFIER.name, + "center": center.toJson(), + "radius": radius, + "zoneName": zoneName, + "alarmType": type.index, + "id": id, + }; + } + + @override + String createNotificationTitle(final l10n, final viewName) { + switch (type) { + case LocationRadiusBasedTriggerType.whenEnter: + return l10n.locationAlarm_radiusBasedRegion_notificationTitle_whenEnter( + viewName, + zoneName, + ); + case LocationRadiusBasedTriggerType.whenLeave: + return l10n.locationAlarm_radiusBasedRegion_notificationTitle_whenLeave( + viewName, + zoneName, + ); + } + } + + // Checks if a given location was inside. If not, it must be outside + LocationAlarmTriggerType _wasInside(final LocationPointService location) { + final fullDistance = Geolocator.distanceBetween( + location.latitude, + location.longitude, + center.latitude, + center.longitude, + ); + + if (fullDistance < radius && location.accuracy < radius) { + return LocationAlarmTriggerType.yes; + } + + if (fullDistance - location.accuracy - radius > 0) { + return LocationAlarmTriggerType.no; + } + + return LocationAlarmTriggerType.maybe; + } + + @override + LocationAlarmTriggerType check( + final previousLocation, + final nextLocation, { + final LocationPointService? userLocation, + }) { + final previousInside = _wasInside(previousLocation); + final nextInside = _wasInside(nextLocation); + + switch (type) { + case LocationRadiusBasedTriggerType.whenEnter: + if (previousInside == LocationAlarmTriggerType.no && + nextInside == LocationAlarmTriggerType.yes) { + return LocationAlarmTriggerType.yes; + } + + if (previousInside == LocationAlarmTriggerType.maybe && + nextInside == LocationAlarmTriggerType.yes) { + return LocationAlarmTriggerType.yes; + } + + if (previousInside == LocationAlarmTriggerType.no && + nextInside == LocationAlarmTriggerType.maybe) { + return LocationAlarmTriggerType.maybe; + } + + if (previousInside == LocationAlarmTriggerType.maybe && + nextInside == LocationAlarmTriggerType.maybe) { + return LocationAlarmTriggerType.maybe; + } + break; + case LocationRadiusBasedTriggerType.whenLeave: + if (previousInside == LocationAlarmTriggerType.yes && + nextInside == LocationAlarmTriggerType.no) { + return LocationAlarmTriggerType.yes; + } + + if (previousInside == LocationAlarmTriggerType.maybe && + nextInside == LocationAlarmTriggerType.no) { + return LocationAlarmTriggerType.yes; + } + + if (previousInside == LocationAlarmTriggerType.yes && + nextInside == LocationAlarmTriggerType.maybe) { + return LocationAlarmTriggerType.maybe; + } + + if (previousInside == LocationAlarmTriggerType.maybe && + nextInside == LocationAlarmTriggerType.maybe) { + return LocationAlarmTriggerType.maybe; + } + break; + } + + return LocationAlarmTriggerType.no; + } +} diff --git a/lib/services/location_alarm_service/LocationAlarmServiceBase.dart b/lib/services/location_alarm_service/LocationAlarmServiceBase.dart new file mode 100644 index 00000000..1f7367b5 --- /dev/null +++ b/lib/services/location_alarm_service/LocationAlarmServiceBase.dart @@ -0,0 +1,27 @@ +import 'package:locus/services/location_point_service.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import 'enums.dart'; + +abstract class LocationAlarmServiceBase { + final String id; + + LocationAlarmType get IDENTIFIER; + + String createNotificationTitle( + final AppLocalizations l10n, final String viewName); + + Map toJSON(); + + // Checks if the alarm should be triggered + // This function will be called each time the background fetch is updated and there are new locations + LocationAlarmTriggerType check( + final LocationPointService previousLocation, + final LocationPointService nextLocation, { + required final LocationPointService userLocation, + }); + + String getStorageKey() => "location_alarm_service:$IDENTIFIER:$id"; + + const LocationAlarmServiceBase(this.id); +} diff --git a/lib/services/location_alarm_service/ProximityLocationAlarm.dart b/lib/services/location_alarm_service/ProximityLocationAlarm.dart new file mode 100644 index 00000000..f6825a3a --- /dev/null +++ b/lib/services/location_alarm_service/ProximityLocationAlarm.dart @@ -0,0 +1,145 @@ +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:locus/services/location_alarm_service/enums.dart'; +import 'package:locus/services/location_point_service.dart'; + +import 'LocationAlarmServiceBase.dart'; + +class ProximityLocationAlarm extends LocationAlarmServiceBase { + // Radius in meters + final double radius; + final LocationRadiusBasedTriggerType type; + + const ProximityLocationAlarm({ + required this.radius, + required this.type, + required String id, + }) : super(id); + + @override + LocationAlarmType get IDENTIFIER => LocationAlarmType.proximity; + + factory ProximityLocationAlarm.fromJSON( + final Map data, + ) => + ProximityLocationAlarm( + radius: data["radius"], + type: LocationRadiusBasedTriggerType.values[data["alarmType"]], + id: data["id"], + ); + + factory ProximityLocationAlarm.create({ + required final double radius, + required final LocationRadiusBasedTriggerType type, + }) => + ProximityLocationAlarm( + radius: radius, + type: type, + id: uuid.v4(), + ); + + LocationAlarmTriggerType _wasInside( + final LocationPointService location, + final LocationPointService userLocation, + ) { + final fullDistance = Geolocator.distanceBetween( + location.latitude, + location.longitude, + userLocation.latitude, + userLocation.longitude, + ); + + if (fullDistance < radius && location.accuracy < radius) { + return LocationAlarmTriggerType.yes; + } + + if (fullDistance - location.accuracy - radius > 0) { + return LocationAlarmTriggerType.no; + } + + return LocationAlarmTriggerType.maybe; + } + + @override + LocationAlarmTriggerType check( + LocationPointService previousLocation, + LocationPointService nextLocation, { + required LocationPointService userLocation, + }) { + final previousInside = _wasInside(previousLocation, userLocation); + final nextInside = _wasInside(nextLocation, userLocation); + + switch (type) { + case LocationRadiusBasedTriggerType.whenEnter: + if (previousInside == LocationAlarmTriggerType.no && + nextInside == LocationAlarmTriggerType.yes) { + return LocationAlarmTriggerType.yes; + } + + if (previousInside == LocationAlarmTriggerType.maybe && + nextInside == LocationAlarmTriggerType.yes) { + return LocationAlarmTriggerType.yes; + } + + if (previousInside == LocationAlarmTriggerType.no && + nextInside == LocationAlarmTriggerType.maybe) { + return LocationAlarmTriggerType.maybe; + } + + if (previousInside == LocationAlarmTriggerType.maybe && + nextInside == LocationAlarmTriggerType.maybe) { + return LocationAlarmTriggerType.maybe; + } + break; + case LocationRadiusBasedTriggerType.whenLeave: + if (previousInside == LocationAlarmTriggerType.yes && + nextInside == LocationAlarmTriggerType.no) { + return LocationAlarmTriggerType.yes; + } + + if (previousInside == LocationAlarmTriggerType.maybe && + nextInside == LocationAlarmTriggerType.no) { + return LocationAlarmTriggerType.yes; + } + + if (previousInside == LocationAlarmTriggerType.yes && + nextInside == LocationAlarmTriggerType.maybe) { + return LocationAlarmTriggerType.maybe; + } + + if (previousInside == LocationAlarmTriggerType.maybe && + nextInside == LocationAlarmTriggerType.maybe) { + return LocationAlarmTriggerType.maybe; + } + break; + } + + return LocationAlarmTriggerType.no; + } + + @override + String createNotificationTitle(AppLocalizations l10n, String viewName) { + switch (type) { + case LocationRadiusBasedTriggerType.whenEnter: + return l10n.locationAlarm_proximityLocation_notificationTitle_whenEnter( + viewName, + radius.round(), + ); + case LocationRadiusBasedTriggerType.whenLeave: + return l10n.locationAlarm_proximityLocation_notificationTitle_whenLeave( + viewName, + radius.round(), + ); + } + } + + @override + Map toJSON() { + return { + "_IDENTIFIER": IDENTIFIER.name, + "radius": radius, + "alarmType": type.index, + "id": id, + }; + } +} diff --git a/lib/services/location_alarm_service/enums.dart b/lib/services/location_alarm_service/enums.dart new file mode 100644 index 00000000..27a3fe48 --- /dev/null +++ b/lib/services/location_alarm_service/enums.dart @@ -0,0 +1,15 @@ +enum LocationAlarmTriggerType { + yes, + no, + maybe, +} + +enum LocationAlarmType { + geo, + proximity, +} + +enum LocationRadiusBasedTriggerType { + whenEnter, + whenLeave, +} diff --git a/lib/services/location_alarm_service/index.dart b/lib/services/location_alarm_service/index.dart new file mode 100644 index 00000000..b2d92661 --- /dev/null +++ b/lib/services/location_alarm_service/index.dart @@ -0,0 +1,2 @@ +export "GeoLocationAlarm.dart"; +export "utils.dart"; diff --git a/lib/services/location_alarm_service/utils.dart b/lib/services/location_alarm_service/utils.dart new file mode 100644 index 00000000..ef2b523a --- /dev/null +++ b/lib/services/location_alarm_service/utils.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import 'enums.dart'; + +Icon getIconForLocationRadiusBasedTrigger( + final BuildContext context, + final LocationRadiusBasedTriggerType type, +) { + switch (type) { + case LocationRadiusBasedTriggerType.whenEnter: + return const Icon(Icons.arrow_circle_right_rounded); + case LocationRadiusBasedTriggerType.whenLeave: + return const Icon(Icons.arrow_circle_left_rounded); + } +} diff --git a/lib/services/location_base.dart b/lib/services/location_base.dart deleted file mode 100644 index fd156aac..00000000 --- a/lib/services/location_base.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:cryptography/cryptography.dart'; -import 'package:flutter/foundation.dart'; -import 'package:locus/services/location_fetch_controller.dart'; -import 'package:locus/services/location_point_service.dart'; - -import '../api/get-locations.dart' as get_locations_api; - -mixin LocationBase { - late final SecretKey _encryptionPassword; - late final List relays; - late final String nostrPublicKey; - - VoidCallback getLocations({ - required void Function(LocationPointService) onLocationFetched, - required void Function() onEnd, - int? limit, - DateTime? from, - }) => - get_locations_api.getLocations( - encryptionPassword: _encryptionPassword, - nostrPublicKey: nostrPublicKey, - relays: relays, - onLocationFetched: onLocationFetched, - onEnd: onEnd, - from: from, - limit: limit, - ); - - LocationFetcher createLocationFetcher({ - required void Function(LocationPointService) onLocationFetched, - int? limit, - DateTime? from, - }) => - LocationFetcher( - location: this, - onLocationFetched: onLocationFetched, - limit: limit, - from: from, - ); -} diff --git a/lib/services/location_fetch_controller.dart b/lib/services/location_fetch_controller.dart deleted file mode 100644 index 5b6fc079..00000000 --- a/lib/services/location_fetch_controller.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -import '../widgets/LocationsMap.dart'; -import 'location_base.dart'; -import 'location_point_service.dart'; - -const INITIAL_LOAD_AMOUNT = 10; -const LOAD_MORE_AMOUNT = 100; - -class LocationFetcher extends ChangeNotifier { - final LocationBase location; - final LocationsMapController controller; - final Set _locationIDS = {}; - - final void Function(LocationPointService) onLocationFetched; - final int? limit; - final DateTime? from; - - VoidCallback? _getLocationsUnsubscribe; - - bool _hasLoaded = false; - - // There can be an edge case, where there are exactly as many locations - // available as the limit. - // Here we save the amount of locations fetched during a fetch. - // If it is 0, we know that there are no more locations available. - int _moreFetchAmount = 0; - - LocationFetcher({ - required this.location, - required this.onLocationFetched, - this.limit, - this.from, - }) : controller = LocationsMapController(), - super(); - - DateTime? get earliestDate => - controller.locations.isEmpty ? null : controller.locations.last.createdAt; - - bool get canFetchMore => - _hasLoaded && - _moreFetchAmount != 0 && - (controller.locations.length - INITIAL_LOAD_AMOUNT) % LOAD_MORE_AMOUNT == - 0 && - // Make sure `earliestDate` is after `from`, if both are set - (from == null || earliestDate == null || earliestDate!.isAfter(from!)); - - bool get isLoading => !_hasLoaded; - - void fetchMore({ - required void Function() onEnd, - }) { - _hasLoaded = false; - // If `from` is specified, we don't want to limit the amount of locations - // by default - final fetchMoreLimit = from == null - ? controller.locations.isEmpty - ? INITIAL_LOAD_AMOUNT - : controller.locations.length + LOAD_MORE_AMOUNT - : null; - _moreFetchAmount = 0; - - _getLocationsUnsubscribe = location.getLocations( - onEnd: () { - _hasLoaded = true; - controller.sort(); - - notifyListeners(); - - onEnd(); - }, - onLocationFetched: (final location) { - if (_locationIDS.contains(location.id)) { - return; - } - - _moreFetchAmount++; - - controller.add(location); - _locationIDS.add(location.id); - onLocationFetched(location); - }, - limit: limit ?? fetchMoreLimit, - from: from, - ); - - notifyListeners(); - } - - @override - void dispose() { - controller.dispose(); - _getLocationsUnsubscribe?.call(); - - super.dispose(); - } -} diff --git a/lib/services/location_fetcher_service/Fetcher.dart b/lib/services/location_fetcher_service/Fetcher.dart new file mode 100644 index 00000000..42f12155 --- /dev/null +++ b/lib/services/location_fetcher_service/Fetcher.dart @@ -0,0 +1,137 @@ +import 'dart:collection'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:locus/services/location_fetcher_service/Locations.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/nostr_fetcher/NostrSocket.dart'; +import 'package:nostr/nostr.dart'; + +class Fetcher extends ChangeNotifier { + final TaskView view; + final Locations _locations = Locations(); + + final List _sockets = []; + + bool _isMounted = true; + bool _isLoading = false; + bool _hasFetchedPreviewLocations = false; + bool _hasFetchedAllLocations = false; + + UnmodifiableSetView get locations => + _locations.locations; + + List get sortedLocations => _locations.sortedLocations; + + bool get isLoading => _isLoading; + + bool get hasFetchedPreviewLocations => _hasFetchedPreviewLocations; + + bool get hasFetchedAllLocations => _hasFetchedAllLocations; + + Fetcher(this.view); + + Future _getLocations(final Request request,) async { + _isLoading = true; + notifyListeners(); + + try { + for (final relay in view.relays) { + try { + final socket = NostrSocket( + relay: relay, + decryptMessage: view.decryptFromNostrMessage, + ); + socket.stream.listen((location) { + _locations.add(location); + }); + await socket.connect(); + socket.addData(request.serialize()); + + _sockets.add(socket); + } on SocketException catch (error) { + continue; + } + } + + await Future.wait(_sockets.map((socket) => socket.onComplete)); + } catch (error) {} finally { + _isLoading = false; + notifyListeners(); + } + } + + Future fetchPreviewLocations() async { + await _getLocations(Request( + generate64RandomHexChars(), + [ + NostrSocket.createNostrRequestDataFromTask( + view, + from: DateTime.now().subtract(const Duration(hours: 24)), + ), + NostrSocket.createNostrRequestDataFromTask( + view, + limit: 1, + ), + ], + )); + + _hasFetchedPreviewLocations = true; + } + + Future fetchMoreLocations([ + int limit = 50, + ]) async { + final previousAmount = _locations.locations.length; + final earliestLocation = _locations.sortedLocations.first; + + await _getLocations(Request( + generate64RandomHexChars(), + [ + NostrSocket.createNostrRequestDataFromTask( + view, + limit: limit, + until: earliestLocation.createdAt, + ), + ], + )); + + final afterAmount = _locations.locations.length; + + // If amount is same, this means that no more locations are available. + if (afterAmount == previousAmount) { + _hasFetchedAllLocations = true; + } + } + + Future fetchAllLocations() async { + await _getLocations( + Request( + generate64RandomHexChars(), + [ + NostrSocket.createNostrRequestDataFromTask( + view, + ), + ], + ), + ); + + _hasFetchedAllLocations = true; + } + + Future fetchCustom(final Request request) async { + await _getLocations(request); + } + + @override + void dispose() { + _isMounted = false; + + for (final socket in _sockets) { + socket.closeConnection(); + } + + super.dispose(); + } +} diff --git a/lib/services/location_fetcher_service/Locations.dart b/lib/services/location_fetcher_service/Locations.dart new file mode 100644 index 00000000..b7846c9a --- /dev/null +++ b/lib/services/location_fetcher_service/Locations.dart @@ -0,0 +1,20 @@ +import 'dart:collection'; + +import 'package:locus/services/location_point_service.dart'; + +class Locations { + final Set _locations = {}; + + List get sortedLocations => + _locations.toList(growable: false) + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + UnmodifiableSetView get locations => + UnmodifiableSetView(_locations); + + Locations(); + + void add(final LocationPointService location) { + _locations.add(location); + } +} diff --git a/lib/services/location_point_service.dart b/lib/services/location_point_service.dart index 2fe6295c..b1677a3b 100644 --- a/lib/services/location_point_service.dart +++ b/lib/services/location_point_service.dart @@ -1,11 +1,13 @@ import 'dart:convert'; +import 'package:background_locator_2/location_dto.dart'; import 'package:latlong2/latlong.dart'; import 'package:battery_plus/battery_plus.dart'; import 'package:cryptography/cryptography.dart'; import 'package:flutter/services.dart'; import 'package:geolocator/geolocator.dart'; import 'package:locus/utils/cryptography/decrypt.dart'; +import 'package:map_launcher/map_launcher.dart'; import 'package:uuid/uuid.dart'; const uuid = Uuid(); @@ -46,6 +48,18 @@ class LocationPointService { headingAccuracy = headingAccuracy == 0.0 ? null : headingAccuracy, batteryLevel = batteryLevel == 0.0 ? null : batteryLevel; + @override + int get hashCode => Object.hash(id, 0); + + @override + bool operator ==(Object other) { + if (other is LocationPointService) { + return id == other.id; + } + + return false; + } + String formatRawAddress() => "${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}"; @@ -59,6 +73,41 @@ class LocationPointService { accuracy: accuracy, ); + static Future fromLocationDto( + final LocationDto locationDto, [ + final bool addBatteryInfo = true, + ]) async { + BatteryInfo? batteryInfo; + + if (addBatteryInfo) { + try { + batteryInfo = await BatteryInfo.fromCurrent(); + } catch (error) { + if (error is PlatformException) { + // Battery level is unavailable (probably iOS simulator) + } else { + rethrow; + } + } + } + + return LocationPointService( + id: uuid.v4(), + // unix time to DateTime + createdAt: DateTime.fromMillisecondsSinceEpoch(locationDto.time.toInt()), + accuracy: locationDto.accuracy, + latitude: locationDto.latitude, + longitude: locationDto.longitude, + altitude: locationDto.altitude, + speed: locationDto.speed, + speedAccuracy: locationDto.speedAccuracy, + heading: locationDto.heading, + headingAccuracy: null, + batteryLevel: batteryInfo?.batteryLevel, + batteryState: batteryInfo?.batteryState, + ); + } + static LocationPointService fromJSON(Map json) { return LocationPointService( id: json["id"], @@ -100,14 +149,10 @@ class LocationPointService { static Future fromPosition( final Position position, ) async { - double? batteryLevel; - BatteryState? batteryState; + BatteryInfo? batteryInfo; try { - final battery = Battery(); - - batteryLevel = (await battery.batteryLevel) / 100; - batteryState = await battery.batteryState; + batteryInfo = await BatteryInfo.fromCurrent(); } catch (error) { if (error is PlatformException) { // Battery level is unavailable (probably iOS simulator) @@ -126,8 +171,8 @@ class LocationPointService { speed: position.speed, speedAccuracy: position.speedAccuracy, heading: position.heading, - batteryLevel: batteryLevel, - batteryState: batteryState, + batteryLevel: batteryInfo?.batteryLevel, + batteryState: batteryInfo?.batteryState, ); } @@ -163,6 +208,8 @@ class LocationPointService { latitude: latitude, longitude: longitude, altitude: altitude ?? 0.0, + headingAccuracy: headingAccuracy ?? 0.0, + altitudeAccuracy: 0.0, accuracy: accuracy, speed: speed ?? 0.0, speedAccuracy: speedAccuracy ?? 0.0, @@ -172,6 +219,8 @@ class LocationPointService { LatLng asLatLng() => LatLng(latitude, longitude); + Coords asCoords() => Coords(latitude, longitude); + LocationPointService copyWith({ final double? latitude, final double? longitude, @@ -200,3 +249,22 @@ class LocationPointService { isCopy: true, ); } + +class BatteryInfo { + final double batteryLevel; + final BatteryState batteryState; + + const BatteryInfo({ + required this.batteryLevel, + required this.batteryState, + }); + + static Future fromCurrent() async { + final battery = Battery(); + + return BatteryInfo( + batteryLevel: (await battery.batteryLevel) / 100, + batteryState: (await battery.batteryState), + ); + } +} diff --git a/lib/services/manager_service.dart b/lib/services/manager_service.dart deleted file mode 100644 index e293b772..00000000 --- a/lib/services/manager_service.dart +++ /dev/null @@ -1,398 +0,0 @@ -import 'dart:convert'; - -import 'package:background_fetch/background_fetch.dart'; -import 'package:basic_utils/basic_utils.dart'; -import 'package:battery_plus/battery_plus.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.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_alarm_service.dart'; -import 'package:locus/services/location_point_service.dart'; -import 'package:locus/services/settings_service.dart'; -import 'package:locus/services/task_service.dart'; -import 'package:locus/services/view_service.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:locus/utils/location/index.dart'; - -import '../models/log.dart'; -import 'log_service.dart'; - -Future updateLocation() async { - final taskService = await TaskService.restore(); - final logService = await LogService.restore(); - - await taskService.checkup(logService); - final runningTasks = await taskService.getRunningTasks().toList(); - - FlutterLogs.logInfo(LOG_TAG, "Headless Task; Update Location", - "Everything restored, now checking for running tasks."); - - if (runningTasks.isEmpty) { - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task; Update Location", - "No tasks to run available", - ); - return; - } - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task; Update Location", - "Fetching position now...", - ); - late final Position position; - - try { - position = await getCurrentPosition(timeouts: [ - 3.minutes, - ]); - } catch (error) { - FlutterLogs.logError( - LOG_TAG, - "Headless Task; Update Location", - "Error while fetching position: $error", - ); - return; - } - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task; Update Location", - "Fetching position now... Done!", - ); - - final locationData = await LocationPointService.fromPosition( - position, - ); - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task; Update Location", - "Publishing position to ${runningTasks.length} tasks...", - ); - for (final task in runningTasks) { - await task.publishLocation(locationData.copyWithDifferentId()); - } - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task; Update Location", - "Publishing position to ${runningTasks.length} tasks... Done!", - ); - - await logService.addLog( - Log.updateLocation( - initiator: LogInitiator.system, - latitude: locationData.latitude, - longitude: locationData.longitude, - accuracy: locationData.accuracy, - tasks: List.from( - runningTasks.map( - (task) => UpdatedTaskData( - id: task.id, - name: task.name, - ), - ), - ), - ), - ); -} - -Future checkViewAlarms({ - required final AppLocalizations l10n, - required final Iterable views, - required final ViewService viewService, -}) async { - for (final view in views) { - await view.checkAlarm( - onTrigger: (alarm, location, __) async { - if (alarm is RadiusBasedRegionLocationAlarm) { - final flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); - - flutterLocalNotificationsPlugin.show( - int.parse( - "${location.createdAt.millisecond}${location.createdAt.microsecond}"), - StringUtils.truncate( - l10n.locationAlarm_radiusBasedRegion_notificationTitle_whenEnter( - view.name, - "test", - ), - 76, - ), - l10n.locationAlarm_notification_description, - NotificationDetails( - android: AndroidNotificationDetails( - AndroidChannelIDs.locationAlarms.name, - l10n.androidNotificationChannel_locationAlarms_name, - channelDescription: - l10n.androidNotificationChannel_locationAlarms_description, - importance: Importance.max, - priority: Priority.max, - ), - ), - payload: jsonEncode({ - "type": NotificationActionType.openTaskView.index, - "taskViewID": view.id, - }), - ); - } - }, - onMaybeTrigger: (alarm, _, __) async { - if (view.lastMaybeTrigger != null && - view.lastMaybeTrigger!.difference(DateTime.now()).abs() < - MAYBE_TRIGGER_MINIMUM_TIME_BETWEEN) { - return; - } - - if (alarm is RadiusBasedRegionLocationAlarm) { - final flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); - - flutterLocalNotificationsPlugin.show( - int.parse( - "${DateTime.now().millisecond}${DateTime.now().microsecond}"), - StringUtils.truncate( - l10n.locationAlarm_radiusBasedRegion_notificationTitle_whenEnter( - view.name, - alarm.zoneName, - ), - 76, - ), - l10n.locationAlarm_notification_description, - NotificationDetails( - android: AndroidNotificationDetails( - AndroidChannelIDs.locationAlarms.name, - l10n.locationAlarm_radiusBasedRegion_notificationTitle_maybe( - view.name, - alarm.zoneName, - ), - channelDescription: - l10n.androidNotificationChannel_locationAlarms_description, - importance: Importance.max, - priority: Priority.max, - ), - ), - payload: jsonEncode({ - "type": NotificationActionType.openTaskView.index, - "taskViewID": view.id, - }), - ); - - view.lastMaybeTrigger = DateTime.now(); - await viewService.update(view); - } - }, - ); - } - - await viewService.save(); -} - -Future _checkViewAlarms() async { - final viewService = await ViewService.restore(); - final settings = await SettingsService.restore(); - final alarmsViews = viewService.viewsWithAlarms; - final locale = Locale(settings.localeName); - final l10n = await AppLocalizations.delegate.load(locale); - - if (alarmsViews.isEmpty) { - return; - } - - checkViewAlarms( - l10n: l10n, - views: alarmsViews, - viewService: viewService, - ); -} - -@pragma('vm:entry-point') -void backgroundFetchHeadlessTask(HeadlessTask task) async { - String taskId = task.taskId; - bool isTimeout = task.timeout; - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Running headless task with ID $taskId", - ); - - if (isTimeout) { - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Task $taskId timed out.", - ); - - BackgroundFetch.finish(taskId); - return; - } - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Starting headless task with ID $taskId now...", - ); - - await runHeadlessTask(); - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Starting headless task with ID $taskId now... Done!", - ); - - BackgroundFetch.finish(taskId); -} - -Future isBatterySaveModeEnabled() async { - try { - final value = await Battery().isInBatterySaveMode; - return value; - } catch (_) { - return false; - } -} - -Future runHeadlessTask() async { - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Restoring settings.", - ); - - final settings = await SettingsService.restore(); - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Checking battery saver.", - ); - final isDeviceBatterySaverEnabled = await isBatterySaveModeEnabled(); - - if ((isDeviceBatterySaverEnabled || settings.alwaysUseBatterySaveMode) && - settings.lastHeadlessRun != null && - DateTime.now().difference(settings.lastHeadlessRun!).abs() <= - BATTERY_SAVER_ENABLED_MINIMUM_TIME_BETWEEN_HEADLESS_RUNS) { - // We don't want to run the headless task too often when the battery saver is enabled. - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Battery saver mode is enabled and the last headless run was too recent. Skipping headless task.", - ); - return; - } - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Executing headless task now.", - ); - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Updating Location...", - ); - await updateLocation(); - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Updating Location... Done!", - ); - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Checking View alarms...", - ); - await _checkViewAlarms(); - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Checking View alarms... Done!", - ); - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Updating settings' lastRun.", - ); - - settings.lastHeadlessRun = DateTime.now(); - await settings.save(); - - FlutterLogs.logInfo( - LOG_TAG, - "Headless Task", - "Finished headless task.", - ); -} - -void registerBackgroundFetch() { - FlutterLogs.logInfo( - LOG_TAG, - "Background Fetch", - "Registering headless task...", - ); - - BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); - - FlutterLogs.logInfo( - LOG_TAG, - "Background Fetch", - "Registering headless task... Done!", - ); -} - -Future configureBackgroundFetch() async { - FlutterLogs.logInfo( - LOG_TAG, - "Background Fetch", - "Configuring background fetch...", - ); - - try { - BackgroundFetch.configure( - BackgroundFetchConfig( - minimumFetchInterval: 15, - requiresCharging: false, - enableHeadless: true, - requiredNetworkType: NetworkType.ANY, - requiresBatteryNotLow: false, - requiresDeviceIdle: false, - requiresStorageNotLow: false, - startOnBoot: true, - stopOnTerminate: false, - ), - (taskId) async { - // We only use one taskId to update the location for all tasks, - // so we don't need to check the taskId. - await runHeadlessTask(); - - BackgroundFetch.finish(taskId); - }, - (taskId) { - // Timeout, we need to finish immediately. - BackgroundFetch.finish(taskId); - }, - ); - - FlutterLogs.logInfo( - LOG_TAG, - "Background Fetch", - "Configuring background fetch. Configuring... Done!", - ); - } catch (error) { - FlutterLogs.logError( - LOG_TAG, - "Background Fetch", - "Configuring background fetch. Configuring... Failed! $error", - ); - return; - } -} diff --git a/lib/services/manager_service/background_fetch.dart b/lib/services/manager_service/background_fetch.dart new file mode 100644 index 00000000..7b8b2404 --- /dev/null +++ b/lib/services/manager_service/background_fetch.dart @@ -0,0 +1,124 @@ +import 'package:background_fetch/background_fetch.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/services/manager_service/helpers.dart'; +import 'package:locus/services/manager_service/task.dart'; + +@pragma('vm:entry-point') +void backgroundFetchHeadlessTask(HeadlessTask task) async { + String taskId = task.taskId; + bool isTimeout = task.timeout; + + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Running headless task with ID $taskId", + ); + + if (isTimeout) { + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Task $taskId timed out.", + ); + + BackgroundFetch.finish(taskId); + return; + } + + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Starting headless task with ID $taskId now...", + ); + + await runBackgroundTask(); + + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Starting headless task with ID $taskId now... Done!", + ); + + BackgroundFetch.finish(taskId); +} + +Future configureBackgroundFetch() async { + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Configuring background fetch...", + ); + + try { + BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 15, + requiresCharging: false, + enableHeadless: true, + requiredNetworkType: NetworkType.ANY, + requiresBatteryNotLow: false, + requiresDeviceIdle: false, + requiresStorageNotLow: false, + startOnBoot: true, + stopOnTerminate: false, + ), + (taskId) async { + // We only use one taskId to update the location for all tasks, + // so we don't need to check the taskId. + await runBackgroundTask(); + + BackgroundFetch.finish(taskId); + }, + (taskId) { + // Timeout, we need to finish immediately. + BackgroundFetch.finish(taskId); + }, + ); + + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Configuring background fetch. Configuring... Done!", + ); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Background Fetch", + "Configuring background fetch. Configuring... Failed! $error", + ); + return; + } +} + +void registerBackgroundFetch() { + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Registering headless task...", + ); + + BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); + + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Registering headless task... Done!", + ); +} + +void removeBackgroundFetch() { + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Removing headless task...", + ); + + BackgroundFetch.stop(); + + FlutterLogs.logInfo( + LOG_TAG, + "Background Fetch", + "Removing headless task... Done!", + ); +} diff --git a/lib/services/manager_service/background_locator.dart b/lib/services/manager_service/background_locator.dart new file mode 100644 index 00000000..5401924a --- /dev/null +++ b/lib/services/manager_service/background_locator.dart @@ -0,0 +1,114 @@ +import 'package:background_locator_2/background_locator.dart'; +import 'package:background_locator_2/location_dto.dart'; +import 'package:background_locator_2/settings/android_settings.dart'; +import 'package:background_locator_2/settings/ios_settings.dart'; +import 'package:background_locator_2/settings/locator_settings.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/app.dart'; +import 'package:locus/constants/values.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:locus/services/location_point_service.dart'; + +import 'task.dart'; + +@pragma('vm:entry-point') +void runBackgroundLocatorTask(final LocationDto location) async { + FlutterLogs.logInfo( + LOG_TAG, + "Background Locator", + "Running background locator", + ); + + FlutterLogs.logInfo( + LOG_TAG, + "Background Locator", + "Parsing location...", + ); + LocationPointService? locationData; + + try { + locationData = await LocationPointService.fromLocationDto(location); + FlutterLogs.logInfo( + LOG_TAG, + "Background Locator", + "Parsing location... Done!", + ); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Background Locator", + "Error while parsing location: $error", + ); + FlutterLogs.logInfo( + LOG_TAG, + "Background Locator", + "Will try continuing without location data.", + ); + } + + await runBackgroundTask( + locationData: locationData, + ); + + FlutterLogs.logInfo( + LOG_TAG, + "Background Locator", + "Running background locator... Done!", + ); +} + +Future configureBackgroundLocator() { + FlutterLogs.logInfo( + LOG_TAG, + "Background Locator", + "Initializing background locator.", + ); + + return BackgroundLocator.initialize(); +} + +Future initializeBackgroundLocator(final BuildContext context,) { + final l10n = AppLocalizations.of(context); + + FlutterLogs.logInfo( + LOG_TAG, + "Background Locator", + "Registering background locator.", + ); + + return BackgroundLocator.registerLocationUpdate( + runBackgroundLocatorTask, + autoStop: false, + androidSettings: AndroidSettings( + accuracy: LocationAccuracy.HIGH, + distanceFilter: + BACKGROUND_LOCATION_UPDATES_MINIMUM_DISTANCE_FILTER.toDouble(), + client: isGMSFlavor ? LocationClient.google : LocationClient.android, + androidNotificationSettings: AndroidNotificationSettings( + notificationTitle: l10n.backgroundLocator_title, + notificationMsg: l10n.backgroundLocator_text, + notificationBigMsg: l10n.backgroundLocator_text, + notificationChannelName: l10n.backgroundLocator_channelName, + notificationIcon: "ic_quick_actions_share_now", + ), + ), + iosSettings: IOSSettings( + distanceFilter: + BACKGROUND_LOCATION_UPDATES_MINIMUM_DISTANCE_FILTER.toDouble(), + accuracy: LocationAccuracy.HIGH, + showsBackgroundLocationIndicator: true, + stopWithTerminate: false, + ), + ); +} + +Future removeBackgroundLocator() { + FlutterLogs.logInfo( + LOG_TAG, + "Background Locator", + "Removing background locator.", + ); + + return BackgroundLocator.unRegisterLocationUpdate(); +} diff --git a/lib/services/manager_service/helpers.dart b/lib/services/manager_service/helpers.dart new file mode 100644 index 00000000..9deacf75 --- /dev/null +++ b/lib/services/manager_service/helpers.dart @@ -0,0 +1,241 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:basic_utils/basic_utils.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/models/log.dart'; +import 'package:locus/services/location_alarm_service/ProximityLocationAlarm.dart'; +import 'package:locus/services/location_alarm_service/enums.dart'; +import 'package:locus/services/location_alarm_service/index.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/log_service.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'; +import 'package:locus/utils/location/index.dart' as location; + +Future getLocationData() async { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Update Location", + "Fetching position now...", + ); + late final Position position; + + try { + position = await location.getCurrentPosition(); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Headless Task; Update Location", + "Error while fetching position: $error", + ); + throw error; + } + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Update Location", + "Fetching position now... Done!", + ); + + return LocationPointService.fromPosition( + position, + ); +} + +Future updateLocation( + final LocationPointService locationData, +) async { + final taskService = await TaskService.restore(); + final logService = await LogService.restore(); + + await taskService.checkup(logService); + final runningTasks = await taskService.getRunningTasks().toList(); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Update Location", + "Everything restored, now checking for running tasks.", + ); + + if (runningTasks.isEmpty) { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Update Location", + "No tasks to run available", + ); + return; + } + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Update Location", + "Publishing position to ${runningTasks.length} tasks...", + ); + + for (final task in runningTasks) { + await task.publisher.publishOutstandingPositions(); + await task.publisher.publishLocation(locationData.copyWithDifferentId()); + } + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Update Location", + "Publishing position to ${runningTasks.length} tasks... Done!", + ); + + await logService.addLog( + Log.updateLocation( + initiator: LogInitiator.system, + latitude: locationData.latitude, + longitude: locationData.longitude, + accuracy: locationData.accuracy, + tasks: List.from( + runningTasks.map( + (task) => UpdatedTaskData( + id: task.id, + name: task.name, + ), + ), + ), + ), + ); +} + +Future checkViewAlarms({ + required final AppLocalizations l10n, + required final ViewService viewService, + required final LocationPointService userLocation, +}) async { + final views = viewService.viewsWithAlarms; + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Check View Alarms", + "Checking ${views.length} views...", + ); + + for (final view in views) { + final data = await view.alarmHandler.checkAlarm( + userLocation, + ); + final triggerResponse = data.$1; + final alarm = data.$2; + final location = data.$3; + + if (triggerResponse == LocationAlarmTriggerType.yes) { + final notifications = FlutterLocalNotificationsPlugin(); + final id = int.parse( + "${view.id}-${alarm!.id}", + ); + + if (alarm is GeoLocationAlarm) { + notifications.show( + id, + StringUtils.truncate( + alarm.type == LocationRadiusBasedTriggerType.whenEnter + ? l10n + .locationAlarm_radiusBasedRegion_notificationTitle_whenEnter( + view.name, + alarm.zoneName, + ) + : l10n + .locationAlarm_radiusBasedRegion_notificationTitle_whenLeave( + view.name, + alarm.zoneName, + ), + 76, + ), + l10n.locationAlarm_notification_description, + NotificationDetails( + android: AndroidNotificationDetails( + AndroidChannelIDs.locationAlarms.name, + l10n.androidNotificationChannel_locationAlarms_name, + channelDescription: + l10n.androidNotificationChannel_locationAlarms_description, + importance: Importance.high, + priority: Priority.high, + ), + ), + payload: jsonEncode({ + "type": NotificationActionType.openTaskView.index, + "taskViewID": view.id, + }), + ); + return; + } + + if (alarm is ProximityLocationAlarm) { + notifications.show( + id, + StringUtils.truncate( + alarm.type == LocationRadiusBasedTriggerType.whenEnter + ? l10n + .locationAlarm_proximityLocation_notificationTitle_whenEnter( + view.name, + alarm.radius.round(), + ) + : l10n + .locationAlarm_proximityLocation_notificationTitle_whenLeave( + view.name, + alarm.radius.round(), + ), + 76, + ), + l10n.locationAlarm_notification_description, + NotificationDetails( + android: AndroidNotificationDetails( + AndroidChannelIDs.locationAlarms.name, + l10n.androidNotificationChannel_locationAlarms_name, + channelDescription: + l10n.androidNotificationChannel_locationAlarms_description, + importance: Importance.max, + priority: Priority.max, + ), + ), + payload: jsonEncode({ + "type": NotificationActionType.openTaskView.index, + "taskViewID": view.id, + }), + ); + } + } + } + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Check View Alarms", + "Checking ${views.length} views... Done! Saving...", + ); + + await viewService.save(); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Check View Alarms", + "Checking ${views.length} views... Done! Saving... Done!", + ); +} + +Future checkViewAlarmsFromBackground( + final LocationPointService userLocation, { + required final AppLocalizations l10n, +}) async { + final viewService = await ViewService.restore(); + + if (viewService.viewsWithAlarms.isEmpty) { + return; + } + + checkViewAlarms( + l10n: l10n, + viewService: viewService, + userLocation: userLocation, + ); +} diff --git a/lib/services/manager_service/index.dart b/lib/services/manager_service/index.dart new file mode 100644 index 00000000..5f90936f --- /dev/null +++ b/lib/services/manager_service/index.dart @@ -0,0 +1 @@ +export "background_fetch.dart"; diff --git a/lib/services/manager_service/task.dart b/lib/services/manager_service/task.dart new file mode 100644 index 00000000..3f393b51 --- /dev/null +++ b/lib/services/manager_service/task.dart @@ -0,0 +1,151 @@ +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/services/location_point_service.dart'; +import 'package:locus/services/manager_service/helpers.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/utils/device/index.dart'; +import 'package:locus/utils/permissions/has-granted.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +const PERMISSION_MISSING_NOTIFICATION_ID = 394001; + +void _showPermissionMissingNotification({ + required final AppLocalizations l10n, +}) { + final notifications = FlutterLocalNotificationsPlugin(); + + notifications.show( + PERMISSION_MISSING_NOTIFICATION_ID, + l10n.permissionsMissing_title, + l10n.permissionsMissing_message, + NotificationDetails( + android: AndroidNotificationDetails( + AndroidChannelIDs.appIssues.name, + l10n.androidNotificationChannel_appIssues_name, + channelDescription: + l10n.androidNotificationChannel_appIssues_description, + onlyAlertOnce: true, + importance: Importance.max, + priority: Priority.max, + ), + ), + payload: jsonEncode({ + "type": NotificationActionType.openPermissionsSettings.index, + }), + ); +} + +Future runBackgroundTask({ + final LocationPointService? locationData, + final bool force = false, +}) async { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Restoring settings.", + ); + + final settings = await SettingsService.restore(); + + final locale = Locale(settings.localeName); + final l10n = await AppLocalizations.delegate.load(locale); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Checking permission.", + ); + + final hasPermission = await hasGrantedAlwaysLocationPermission(); + + if (!hasPermission) { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Permission not granted. Headless task will not run. Showing notification.", + ); + + _showPermissionMissingNotification(l10n: l10n); + + return; + } + + if (!force) { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Checking battery saver.", + ); + final isDeviceBatterySaverEnabled = await isBatterySaveModeEnabled(); + + if ((isDeviceBatterySaverEnabled || settings.alwaysUseBatterySaveMode) && + settings.lastHeadlessRun != null && + DateTime.now().difference(settings.lastHeadlessRun!).abs() <= + BATTERY_SAVER_ENABLED_MINIMUM_TIME_BETWEEN_HEADLESS_RUNS) { + // We don't want to run the headless task too often when the battery saver is enabled. + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Battery saver mode is enabled and the last headless run was too recent. Skipping headless task.", + ); + return; + } + } else { + FlutterLogs.logInfo(LOG_TAG, "Headless Task", "Execution is being forced."); + } + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Executing headless task now.", + ); + + final location = locationData ?? await getLocationData(); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Updating Location...", + ); + await updateLocation(location); + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Updating Location... Done!", + ); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Checking View alarms...", + ); + await checkViewAlarmsFromBackground( + location, + l10n: l10n, + ); + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Checking View alarms... Done!", + ); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Updating settings' lastRun.", + ); + + settings.lastHeadlessRun = DateTime.now(); + await settings.save(); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task", + "Finished headless task.", + ); +} diff --git a/lib/services/settings_service/SettingsMapLocation.dart b/lib/services/settings_service/SettingsMapLocation.dart new file mode 100644 index 00000000..528b262c --- /dev/null +++ b/lib/services/settings_service/SettingsMapLocation.dart @@ -0,0 +1,29 @@ +import 'package:latlong2/latlong.dart'; + +class SettingsLastMapLocation { + final double latitude; + final double longitude; + final double accuracy; + + const SettingsLastMapLocation({ + required this.latitude, + required this.longitude, + required this.accuracy, + }); + + factory SettingsLastMapLocation.fromJSON(final Map data) => + SettingsLastMapLocation( + latitude: data['latitude'] as double, + longitude: data['longitude'] as double, + accuracy: data['accuracy'] as double, + ); + + Map toJSON() => + { + 'latitude': latitude, + 'longitude': longitude, + 'accuracy': accuracy, + }; + + LatLng toLatLng() => LatLng(latitude, longitude); +} diff --git a/lib/services/settings_service.dart b/lib/services/settings_service/SettingsService.dart similarity index 81% rename from lib/services/settings_service.dart rename to lib/services/settings_service/SettingsService.dart index c1d28be7..25346c19 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service/SettingsService.dart @@ -1,84 +1,30 @@ import 'dart:collection'; import 'dart:convert'; import 'dart:io'; -import 'dart:math'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_logs/flutter_logs.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:local_auth/local_auth.dart'; +import 'package:locus/api/get-address.dart'; import 'package:locus/api/nostr-relays.dart'; import 'package:locus/constants/app.dart'; import 'package:locus/constants/values.dart'; +import 'package:locus/utils/cache.dart'; +import 'package:locus/utils/device/index.dart'; import 'package:locus/utils/nostr/select-random-relays.dart'; +import 'package:locus/utils/platform.dart'; -import '../api/get-address.dart'; -import '../utils/cache.dart'; -import '../utils/device.dart'; -import '../utils/platform.dart'; +import 'SettingsMapLocation.dart'; +import 'enums.dart'; +import 'utils.dart'; const STORAGE_KEY = "_app_settings"; const storage = FlutterSecureStorage(); -enum MapProvider { - openStreetMap, - apple, -} - -enum GeocoderProvider { - system, - geocodeMapsCo, - nominatim, -} - -enum AndroidTheme { - materialYou, - miui, -} - -enum HelperSheet { - radiusBasedAlarms, - taskShare, -} - -// Selects a random provider from the list of available providers, not including -// the system provider. -GeocoderProvider selectRandomProvider() { - final providers = GeocoderProvider.values - .where((element) => element != GeocoderProvider.system) - .toList(); - - return providers[Random().nextInt(providers.length)]; -} - -class SettingsLastMapLocation { - final double latitude; - final double longitude; - final double accuracy; - - const SettingsLastMapLocation({ - required this.latitude, - required this.longitude, - required this.accuracy, - }); - - factory SettingsLastMapLocation.fromJSON(final Map data) => - SettingsLastMapLocation( - latitude: data['latitude'] as double, - longitude: data['longitude'] as double, - accuracy: data['accuracy'] as double, - ); - - Map toJSON() => - { - 'latitude': latitude, - 'longitude': longitude, - 'accuracy': accuracy, - }; -} - class SettingsService extends ChangeNotifier { String localeName; bool automaticallyLookupAddresses; @@ -86,6 +32,7 @@ class SettingsService extends ChangeNotifier { bool userHasSeenWelcomeScreen = false; bool requireBiometricAuthenticationOnStart = false; bool alwaysUseBatterySaveMode = false; + bool useRealtimeUpdates = false; String serverOrigin; List _relays; AndroidTheme androidTheme; @@ -117,12 +64,12 @@ class SettingsService extends ChangeNotifier { required this.alwaysUseBatterySaveMode, required this.serverOrigin, required this.currentAppVersion, + required this.useRealtimeUpdates, this.lastHeadlessRun, this.lastMapLocation, Set? seenHelperSheets, List? relays, - }) - : _relays = relays ?? [], + }) : _relays = relays ?? [], _seenHelperSheets = seenHelperSheets ?? {}; static Future createDefault() async { @@ -130,9 +77,9 @@ class SettingsService extends ChangeNotifier { automaticallyLookupAddresses: true, primaryColor: null, androidTheme: - await fetchIsMIUI() ? AndroidTheme.miui : AndroidTheme.materialYou, + await fetchIsMIUI() ? AndroidTheme.miui : AndroidTheme.materialYou, mapProvider: - isPlatformApple() ? MapProvider.apple : MapProvider.openStreetMap, + isPlatformApple() ? MapProvider.apple : MapProvider.openStreetMap, showHints: true, geocoderProvider: isSystemGeocoderAvailable() ? GeocoderProvider.system @@ -146,6 +93,7 @@ class SettingsService extends ChangeNotifier { serverOrigin: "https://locus.cfd", lastMapLocation: null, currentAppVersion: CURRENT_APP_VERSION, + useRealtimeUpdates: true, ); } @@ -156,7 +104,7 @@ class SettingsService extends ChangeNotifier { return SettingsService( automaticallyLookupAddresses: data['automaticallyLoadLocation'], primaryColor: - data['primaryColor'] != null ? Color(data['primaryColor']) : null, + data['primaryColor'] != null ? Color(data['primaryColor']) : null, mapProvider: MapProvider.values[data['mapProvider']], relays: List.from(data['relays'] ?? []), showHints: data['showHints'], @@ -166,7 +114,7 @@ class SettingsService extends ChangeNotifier { userHasSeenWelcomeScreen: data['userHasSeenWelcomeScreen'], seenHelperSheets: Set.from(data['seenHelperSheets'] ?? {}), requireBiometricAuthenticationOnStart: - data['requireBiometricAuthenticationOnStart'], + data['requireBiometricAuthenticationOnStart'], alwaysUseBatterySaveMode: data['alwaysUseBatterySaveMode'], lastHeadlessRun: data['lastHeadlessRun'] != null ? DateTime.parse(data['lastHeadlessRun']) @@ -176,6 +124,7 @@ class SettingsService extends ChangeNotifier { ? SettingsLastMapLocation.fromJSON(data['lastMapLocation']) : null, currentAppVersion: data['currentAppVersion'], + useRealtimeUpdates: data['useRealtimeUpdates'], ); } @@ -211,17 +160,20 @@ class SettingsService extends ChangeNotifier { "userHasSeenWelcomeScreen": userHasSeenWelcomeScreen, "seenHelperSheets": _seenHelperSheets.toList(), "requireBiometricAuthenticationOnStart": - requireBiometricAuthenticationOnStart, + requireBiometricAuthenticationOnStart, "alwaysUseBatterySaveMode": alwaysUseBatterySaveMode, "lastHeadlessRun": lastHeadlessRun?.toIso8601String(), "serverOrigin": serverOrigin, "lastMapLocation": lastMapLocation?.toJSON(), "currentAppVersion": currentAppVersion, + "useRealtimeUpdates": useRealtimeUpdates, }; } - Future getAddress(final double latitude, - final double longitude,) async { + Future getAddress( + final double latitude, + final double longitude, + ) async { final providers = [ getGeocoderProvider(), ...GeocoderProvider.values @@ -245,16 +197,19 @@ class SettingsService extends ChangeNotifier { case GeocoderProvider.nominatim: return await getAddressNominatim(latitude, longitude); } - } catch (e) { - print("Failed to get address from $provider: $e"); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "SettingsService", + "Failed to get address from $provider: $error", + ); } } throw Exception("Failed to get address from any provider"); } - Future save() => - storage.write( + Future save() => storage.write( key: STORAGE_KEY, value: jsonEncode(toJSON()), ); @@ -275,13 +230,9 @@ class SettingsService extends ChangeNotifier { // Return system default if (isCupertino(context)) { - return CupertinoTheme - .of(context) - .primaryColor; + return CupertinoTheme.of(context).primaryColor; } else { - return Theme - .of(context) - .primaryColor; + return Theme.of(context).primaryColor; } } @@ -313,8 +264,8 @@ class SettingsService extends ChangeNotifier { } final relaysData = await withCache(getNostrRelays, "relays")(); - final availableRelays = List.from( - relaysData["relays"] as List); + final availableRelays = + List.from(relaysData["relays"] as List); final relays = await selectRandomRelays(availableRelays); return relays; @@ -368,6 +319,13 @@ class SettingsService extends ChangeNotifier { notifyListeners(); } + bool getUseRealtimeUpdates() => useRealtimeUpdates; + + void setUseRealtimeUpdates(final bool value) { + useRealtimeUpdates = value; + notifyListeners(); + } + Future hasBiometricsAvailable() { final auth = LocalAuthentication(); diff --git a/lib/services/settings_service/enums.dart b/lib/services/settings_service/enums.dart new file mode 100644 index 00000000..943611d1 --- /dev/null +++ b/lib/services/settings_service/enums.dart @@ -0,0 +1,20 @@ +enum MapProvider { + openStreetMap, + apple, +} + +enum GeocoderProvider { + system, + geocodeMapsCo, + nominatim, +} + +enum AndroidTheme { + materialYou, + miui, +} + +enum HelperSheet { + radiusBasedAlarms, + taskShare, +} diff --git a/lib/services/settings_service/index.dart b/lib/services/settings_service/index.dart new file mode 100644 index 00000000..02f2f7d3 --- /dev/null +++ b/lib/services/settings_service/index.dart @@ -0,0 +1,2 @@ +export 'enums.dart'; +export 'SettingsService.dart'; diff --git a/lib/services/settings_service/utils.dart b/lib/services/settings_service/utils.dart new file mode 100644 index 00000000..f1f38e26 --- /dev/null +++ b/lib/services/settings_service/utils.dart @@ -0,0 +1,13 @@ +// Selects a random provider from the list of available providers, not including +// the system provider. +import 'dart:math'; + +import 'enums.dart'; + +GeocoderProvider selectRandomProvider() { + final providers = GeocoderProvider.values + .where((element) => element != GeocoderProvider.system) + .toList(); + + return providers[Random().nextInt(providers.length)]; +} diff --git a/lib/services/task_service.dart b/lib/services/task_service.dart deleted file mode 100644 index c67b194d..00000000 --- a/lib/services/task_service.dart +++ /dev/null @@ -1,660 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'package:cryptography/cryptography.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_logs/flutter_logs.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:locus/api/nostr-events.dart'; -import 'package:locus/constants/values.dart'; -import 'package:locus/models/log.dart'; -import 'package:locus/services/location_base.dart'; -import 'package:locus/services/log_service.dart'; -import 'package:locus/utils/cryptography/encrypt.dart'; -import 'package:locus/utils/cryptography/utils.dart'; -import 'package:locus/utils/location/index.dart'; -import 'package:nostr/nostr.dart'; -import 'package:uuid/uuid.dart'; - -import '../api/get-locations.dart' as get_locations_api; -import 'location_point_service.dart'; -import 'timers_service.dart'; - -const storage = FlutterSecureStorage(); -const KEY = "tasks_settings"; -const SAME_TIME_THRESHOLD = Duration(minutes: 15); - -enum TaskLinkPublishProgress { - startsSoon, - encrypting, - publishing, - creatingURI, - done, -} - -const uuid = Uuid(); - -class Task extends ChangeNotifier with LocationBase { - final String id; - final DateTime createdAt; - - // Password for symmetric encryption of the locations - final SecretKey _encryptionPassword; - - final String nostrPrivateKey; - @override - final List relays; - final List timers; - String name; - bool deleteAfterRun; - - Task({ - required this.id, - required this.name, - required this.createdAt, - required SecretKey encryptionPassword, - required this.nostrPrivateKey, - required this.relays, - required this.timers, - this.deleteAfterRun = false, - }) : _encryptionPassword = encryptionPassword; - - factory Task.fromJSON(Map json) { - return Task( - id: json["id"], - name: json["name"], - encryptionPassword: SecretKey(List.from(json["encryptionPassword"])), - nostrPrivateKey: json["nostrPrivateKey"], - createdAt: DateTime.parse(json["createdAt"]), - relays: List.from(json["relays"]), - deleteAfterRun: json["deleteAfterRun"] == "true", - timers: List.from(json["timers"].map((timer) { - switch (timer["_IDENTIFIER"]) { - case WeekdayTimer.IDENTIFIER: - return WeekdayTimer.fromJSON(timer); - case DurationTimer.IDENTIFIER: - return DurationTimer.fromJSON(timer); - default: - throw Exception("Unknown timer type"); - } - })), - ); - } - - String get taskKey => "Task:$id"; - - String get scheduleKey => "Task:$id:Schedule"; - - @override - String get nostrPublicKey => Keychain(nostrPrivateKey).public; - - Future> toJSON() async { - return { - "id": id, - "name": name, - "encryptionPassword": await _encryptionPassword.extractBytes(), - "nostrPrivateKey": nostrPrivateKey, - "createdAt": createdAt.toIso8601String(), - "relays": relays, - "timers": timers.map((timer) => timer.toJSON()).toList(), - "deleteAfterRun": deleteAfterRun.toString(), - }; - } - - static Future create( - final String name, - final List relays, { - List timers = const [], - bool deleteAfterRun = false, - }) async { - FlutterLogs.logInfo( - LOG_TAG, - "Task", - "Creating new task.", - ); - - final secretKey = await generateSecretKey(); - - return Task( - id: uuid.v4(), - name: name, - encryptionPassword: secretKey, - nostrPrivateKey: Keychain.generate().private, - relays: relays, - createdAt: DateTime.now(), - timers: timers, - deleteAfterRun: deleteAfterRun, - ); - } - - Future isRunning() async { - final status = await getExecutionStatus(); - - return status != null; - } - - Future?> getExecutionStatus() async { - final rawData = await storage.read(key: taskKey); - - if (rawData == null || rawData == "") { - return null; - } - - final data = jsonDecode(rawData); - - return { - ...data, - "startedAt": DateTime.parse(data["startedAt"]), - }; - } - - Future?> getScheduleStatus() async { - final rawData = await storage.read(key: scheduleKey); - - if (rawData == null || rawData == "") { - return null; - } - - final data = jsonDecode(rawData); - - return { - ...data, - "startedAt": DateTime.parse(data["startedAt"]), - "startsAt": DateTime.parse(data["startsAt"]), - }; - } - - DateTime? nextStartDate({final DateTime? date}) => - findNextStartDate(timers, startDate: date); - - DateTime? nextEndDate() => findNextEndDate(timers); - - bool isInfinite() => - timers.any((timer) => timer.isInfinite()) || timers.isEmpty; - - Future shouldRunNow() async { - final executionStatus = await getExecutionStatus(); - - if (timers.isEmpty) { - return executionStatus != null; - } - - final shouldRunNowBasedOnTimers = - timers.any((timer) => timer.shouldRun(DateTime.now())); - - if (shouldRunNowBasedOnTimers) { - return true; - } - - if (executionStatus != null) { - final earliestNextRun = nextStartDate(date: executionStatus["startedAt"]); - - if (earliestNextRun == null) { - return false; - } - - return (executionStatus["startedAt"] as DateTime) - .isBefore(earliestNextRun); - } - - return false; - } - - Future stopSchedule() async { - await storage.delete(key: scheduleKey); - } - - // Starts the task. This will schedule the task to run at the next expected time. - // You can find out when the task will run by calling `nextStartDate`. - // Returns the next start date of the task OR `null` if the task is not scheduled to run. - Future startSchedule({ - final bool startNowIfNextRunIsUnknown = false, - final DateTime? startDate, - }) async { - final now = startDate ?? DateTime.now(); - DateTime? nextStartDate = this.nextStartDate(date: now); - - if (nextStartDate == null) { - if (startNowIfNextRunIsUnknown) { - nextStartDate = now; - } else { - return null; - } - } - - final isNow = nextStartDate.subtract(SAME_TIME_THRESHOLD).isBefore(now); - - if (isNow) { - await startExecutionImmediately(); - } else { - await stopSchedule(); - - await storage.write( - key: scheduleKey, - value: jsonEncode({ - "startedAt": DateTime.now().toIso8601String(), - "startsAt": nextStartDate.toIso8601String(), - }), - ); - } - - return nextStartDate; - } - - // Starts the schedule tomorrow morning. This should be used when the user manually stops the execution of the task, but - // still wants the task to run at the next expected time. If `startSchedule` is used, the schedule might start, - // immediately, which is not what the user wants. - // Returns the next date the task will run OR `null` if the task is not scheduled to run. - Future startScheduleTomorrow() { - final tomorrow = DateTime.now().add(const Duration(days: 1)); - final nextDate = - DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 6, 0, 0); - - return startSchedule(startDate: nextDate); - } - - // Starts the actual execution of the task. You should only call this if either the user wants to manually start the - // task or if the task is scheduled to run. - Future startExecutionImmediately() async { - FlutterLogs.logInfo( - LOG_TAG, - "Task $id", - "Starting execution of task...", - ); - - await storage.write( - key: taskKey, - value: jsonEncode({ - "startedAt": DateTime.now().toIso8601String(), - }), - ); - - await stopSchedule(); - - for (final timer in timers) { - timer.executionStarted(); - } - - notifyListeners(); - - FlutterLogs.logInfo( - LOG_TAG, - "Task $id", - "Execution of task started!", - ); - } - - // Stops the actual execution of the task. You should only call this if either the user wants to manually stop the - // task or if the task is scheduled to stop. - Future stopExecutionImmediately() async { - FlutterLogs.logInfo( - LOG_TAG, - "Task $id", - "Stopping execution of task...", - ); - - await storage.delete(key: taskKey); - - for (final timer in timers) { - timer.executionStopped(); - } - - notifyListeners(); - - FlutterLogs.logInfo( - LOG_TAG, - "Task $id", - "Execution of task stopped!", - ); - } - - Future update({ - String? name, - Iterable? relays, - Iterable? timers, - bool? deleteAfterRun, - }) async { - if (name != null) { - this.name = name; - } - - if (relays != null) { - // We need to copy the relays as they somehow also get cleared when `this.relays.clear` is called. - final newRelays = [...relays]; - this.relays.clear(); - this.relays.addAll(newRelays); - } - - if (timers != null) { - final newTimers = [...timers]; - this.timers.clear(); - this.timers.addAll(newTimers); - } - - if (deleteAfterRun != null) { - this.deleteAfterRun = deleteAfterRun; - } - - notifyListeners(); - } - - Future generateViewKeyContent() async { - return jsonEncode({ - "encryptionPassword": await _encryptionPassword.extractBytes(), - "nostrPublicKey": nostrPublicKey, - "relays": relays, - }); - } - - // Generates a link that can be used to retrieve the task - // This link is primarily used for sharing the task to the web app - // Here's the process: - // 1. Generate a random password - // 2. Encrypt the task with the password - // 3. Publish the encrypted task to a random Nostr relay - // 4. Generate a link that contains the password and the Nostr relay ID - Future generateLink( - final String host, { - final void Function(TaskLinkPublishProgress progress)? onProgress, - }) async { - onProgress?.call(TaskLinkPublishProgress.startsSoon); - - final message = await generateViewKeyContent(); - - onProgress?.call(TaskLinkPublishProgress.encrypting); - - final passwordSecretKey = await generateSecretKey(); - final password = await passwordSecretKey.extractBytes(); - final cipherText = await encryptUsingAES(message, passwordSecretKey); - - onProgress?.call(TaskLinkPublishProgress.publishing); - - final manager = NostrEventsManager( - relays: relays, - privateKey: nostrPrivateKey, - ); - final publishedEvent = await manager.publishMessage(cipherText, kind: 1001); - - onProgress?.call(TaskLinkPublishProgress.creatingURI); - - final parameters = { - // Password - "p": password, - // Key - "k": nostrPublicKey, - // ID - "i": publishedEvent.id, - // Relay - "r": relays, - }; - - final fragment = base64Url.encode(jsonEncode(parameters).codeUnits); - final uri = Uri( - scheme: "https", - host: host, - path: "/", - fragment: fragment, - ); - - onProgress?.call(TaskLinkPublishProgress.done); - passwordSecretKey.destroy(); - - return uri.toString(); - } - - Future publishLocation( - final LocationPointService locationPoint, - ) async { - final eventManager = NostrEventsManager.fromTask(this); - - final rawMessage = jsonEncode(locationPoint.toJSON()); - final message = await encryptUsingAES(rawMessage, _encryptionPassword); - - await eventManager.publishMessage(message); - } - - Future publishCurrentPosition() async { - final position = await getCurrentPosition(); - final locationPoint = await LocationPointService.fromPosition(position); - - await publishLocation(locationPoint); - - return locationPoint; - } - - @override - VoidCallback getLocations({ - required void Function(LocationPointService) onLocationFetched, - required void Function() onEnd, - int? limit, - DateTime? from, - }) => - get_locations_api.getLocations( - encryptionPassword: _encryptionPassword, - nostrPublicKey: nostrPublicKey, - relays: relays, - onLocationFetched: onLocationFetched, - onEnd: onEnd, - from: from, - limit: limit, - ); - - @override - void dispose() { - _encryptionPassword.destroy(); - - super.dispose(); - } -} - -class TaskService extends ChangeNotifier { - final List _tasks; - - TaskService({ - required List tasks, - }) : _tasks = tasks; - - UnmodifiableListView get tasks => UnmodifiableListView(_tasks); - - static Future restore() async { - final rawTasks = await storage.read(key: KEY); - - if (rawTasks == null) { - return TaskService( - tasks: [], - ); - } - - return TaskService( - tasks: List.from( - List>.from( - jsonDecode(rawTasks), - ).map( - Task.fromJSON, - ), - ).toList(), - ); - } - - Future save() async { - FlutterLogs.logInfo( - LOG_TAG, - "Task Service", - "Saving tasks...", - ); - - // await all `toJson` functions - final data = await Future.wait>( - _tasks.map( - (task) => task.toJSON(), - ), - ); - - await storage.write(key: KEY, value: jsonEncode(data)); - FlutterLogs.logInfo( - LOG_TAG, - "Task Service", - "Saved tasks successfully!", - ); - } - - Task getByID(final String id) { - return _tasks.firstWhere((task) => task.id == id); - } - - void add(Task task) { - _tasks.add(task); - - notifyListeners(); - } - - void remove(final Task task) { - task.stopExecutionImmediately(); - _tasks.remove(task); - - notifyListeners(); - } - - void forceListenerUpdate() { - notifyListeners(); - } - - void update(final Task task) { - final index = _tasks.indexWhere((element) => element.id == task.id); - - _tasks[index] = task; - - notifyListeners(); - save(); - } - - // Does a general check up state of the task. - // Checks if the task should be running / should be deleted etc. - Future checkup(final LogService logService) async { - FlutterLogs.logInfo(LOG_TAG, "Task Service", "Doing checkup..."); - - final tasksToRemove = {}; - - for (final task in tasks) { - final isRunning = await task.isRunning(); - final shouldRun = await task.shouldRunNow(); - final isQuickShare = task.deleteAfterRun && - task.timers.length == 1 && - task.timers[0] is DurationTimer; - - if (isQuickShare) { - final durationTimer = task.timers[0] as DurationTimer; - - if (durationTimer.startDate != null && !shouldRun) { - FlutterLogs.logInfo(LOG_TAG, "Task Service", "Removing task."); - - tasksToRemove.add(task); - } - } else { - if ((!task.isInfinite() && task.nextEndDate() == null)) { - FlutterLogs.logInfo(LOG_TAG, "Task Service", "Removing task."); - - tasksToRemove.add(task); - } else if (!shouldRun && isRunning) { - FlutterLogs.logInfo(LOG_TAG, "Task Service", "Stopping task."); - await task.stopExecutionImmediately(); - - await logService.addLog( - Log.taskStatusChanged( - initiator: LogInitiator.system, - taskId: task.id, - taskName: task.name, - active: false, - ), - ); - } else if (shouldRun && !isRunning) { - FlutterLogs.logInfo(LOG_TAG, "Task Service", "Start task."); - await task.startExecutionImmediately(); - - await logService.addLog( - Log.taskStatusChanged( - initiator: LogInitiator.system, - taskId: task.id, - taskName: task.name, - active: true, - ), - ); - } - } - } - - for (final task in tasksToRemove) { - remove(task); - } - - await save(); - - FlutterLogs.logInfo(LOG_TAG, "Task Service", "Checkup done."); - } - - Stream getRunningTasks() async* { - for (final task in tasks) { - if (await task.isRunning()) { - yield task; - } - } - } -} - -class TaskExample { - final String name; - final List timers; - final bool realtime; - - const TaskExample({ - required this.name, - required this.timers, - this.realtime = false, - }); -} - -DateTime? findNextStartDate(final List timers, - {final DateTime? startDate, final bool onlyFuture = true}) { - final now = startDate ?? DateTime.now(); - - final nextDates = timers - .map((timer) => timer.nextStartDate(now)) - .where((date) => date != null && (date.isAfter(now) || date == now)) - .toList(growable: false); - - if (nextDates.isEmpty) { - return null; - } - - // Find earliest date - nextDates.sort(); - return nextDates.first; -} - -DateTime? findNextEndDate( - final List timers, { - final DateTime? startDate, -}) { - final now = startDate ?? DateTime.now(); - final nextDates = List.from( - timers.map((timer) => timer.nextEndDate(now)).where((date) => date != null), - )..sort(); - - if (nextDates.isEmpty) { - return null; - } - - DateTime endDate = nextDates.first; - - for (final date in nextDates.sublist(1)) { - final nextStartDate = findNextStartDate(timers, startDate: date); - if (nextStartDate == null || - nextStartDate.difference(date).inMinutes.abs() > 15) { - // No next start date found or the difference is more than 15 minutes, so this is the last date - break; - } - endDate = date; - } - - return endDate; -} diff --git a/lib/services/task_service/constants.dart b/lib/services/task_service/constants.dart new file mode 100644 index 00000000..c3f7cf74 --- /dev/null +++ b/lib/services/task_service/constants.dart @@ -0,0 +1,5 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +const KEY = "tasks_settings"; +const SAME_TIME_THRESHOLD = Duration(minutes: 15); +const storage = FlutterSecureStorage(); diff --git a/lib/services/task_service/enums.dart b/lib/services/task_service/enums.dart new file mode 100644 index 00000000..03e23def --- /dev/null +++ b/lib/services/task_service/enums.dart @@ -0,0 +1,7 @@ +enum TaskLinkPublishProgress { + startsSoon, + encrypting, + publishing, + creatingURI, + done, +} diff --git a/lib/services/task_service/helpers.dart b/lib/services/task_service/helpers.dart new file mode 100644 index 00000000..cc5cca9d --- /dev/null +++ b/lib/services/task_service/helpers.dart @@ -0,0 +1,47 @@ +import '../timers_service.dart'; + +DateTime? findNextStartDate(final List timers, + {final DateTime? startDate, final bool onlyFuture = true}) { + final now = startDate ?? DateTime.now(); + + final nextDates = timers + .map((timer) => timer.nextStartDate(now)) + .where((date) => date != null && (date.isAfter(now) || date == now)) + .toList(growable: false); + + if (nextDates.isEmpty) { + return null; + } + + // Find earliest date + nextDates.sort(); + return nextDates.first; +} + +DateTime? findNextEndDate( + final List timers, { + final DateTime? startDate, +}) { + final now = startDate ?? DateTime.now(); + final nextDates = List.from( + timers.map((timer) => timer.nextEndDate(now)).where((date) => date != null), + )..sort(); + + if (nextDates.isEmpty) { + return null; + } + + DateTime endDate = nextDates.first; + + for (final date in nextDates.sublist(1)) { + final nextStartDate = findNextStartDate(timers, startDate: date); + if (nextStartDate == null || + nextStartDate.difference(date).inMinutes.abs() > 15) { + // No next start date found or the difference is more than 15 minutes, so this is the last date + break; + } + endDate = date; + } + + return endDate; +} diff --git a/lib/services/task_service/index.dart b/lib/services/task_service/index.dart new file mode 100644 index 00000000..737e66dc --- /dev/null +++ b/lib/services/task_service/index.dart @@ -0,0 +1,5 @@ +export "enums.dart"; +export "task.dart"; +export "task_example.dart"; +export "task_service.dart"; +export "helpers.dart"; diff --git a/lib/services/task_service/mixins.dart b/lib/services/task_service/mixins.dart new file mode 100644 index 00000000..0a9b258a --- /dev/null +++ b/lib/services/task_service/mixins.dart @@ -0,0 +1,7 @@ +import 'package:cryptography/cryptography.dart'; + +mixin LocationBase { + late final SecretKey _encryptionPassword; + late final List relays; + late final String nostrPublicKey; +} diff --git a/lib/services/task_service/task.dart b/lib/services/task_service/task.dart new file mode 100644 index 00000000..25874c3b --- /dev/null +++ b/lib/services/task_service/task.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/api/nostr-events.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/task_service/task_cryptography.dart'; +import 'package:locus/services/task_service/task_publisher.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/cryptography/utils.dart'; +import 'package:nostr/nostr.dart'; +import 'package:uuid/uuid.dart'; + +import '../timers_service.dart'; +import 'constants.dart'; +import 'helpers.dart'; +import 'mixins.dart'; + +const uuid = Uuid(); + +class Task extends ChangeNotifier with LocationBase { + final String id; + final DateTime createdAt; + + // Password for symmetric encryption of the locations + final SecretKey _encryptionPassword; + + final String nostrPrivateKey; + @override + final List relays; + final List timers; + String name; + bool deleteAfterRun; + + // List of location points that need to be published yet + // To avoid infinite retries, we only try to publish each location point a + // certain amount of time + // This ´Map` stores the amount of tries for each location point + late final Map outstandingLocations; + + Task({ + required this.id, + required this.name, + required this.createdAt, + required SecretKey encryptionPassword, + required this.nostrPrivateKey, + required this.relays, + required this.timers, + Map? outstandingLocations, + this.deleteAfterRun = false, + }) : _encryptionPassword = encryptionPassword, + outstandingLocations = outstandingLocations ?? {}; + + factory Task.fromJSON(Map json) { + return Task( + id: json["id"], + name: json["name"], + encryptionPassword: SecretKey(List.from(json["encryptionPassword"])), + nostrPrivateKey: json["nostrPrivateKey"], + createdAt: DateTime.parse(json["createdAt"]), + relays: List.from(json["relays"]), + deleteAfterRun: json["deleteAfterRun"] == "true", + timers: List.from(json["timers"].map((timer) { + switch (timer["_IDENTIFIER"]) { + case WeekdayTimer.IDENTIFIER: + return WeekdayTimer.fromJSON(timer); + case DurationTimer.IDENTIFIER: + return DurationTimer.fromJSON(timer); + default: + throw Exception("Unknown timer type"); + } + })), + outstandingLocations: Map.from(json["outstandingLocations"]) + .map( + (rawLocationData, tries) => MapEntry( + LocationPointService.fromJSON(jsonDecode(rawLocationData)), + tries, + ), + ), + ); + } + + String get taskKey => "Task:$id"; + + String get scheduleKey => "Task:$id:Schedule"; + + @override + String get nostrPublicKey => Keychain(nostrPrivateKey).public; + + Future> toJSON() async { + return { + "id": id, + "name": name, + "encryptionPassword": await _encryptionPassword.extractBytes(), + "nostrPrivateKey": nostrPrivateKey, + "createdAt": createdAt.toIso8601String(), + "relays": relays, + "timers": timers.map((timer) => timer.toJSON()).toList(), + "deleteAfterRun": deleteAfterRun.toString(), + "outstandingLocations": outstandingLocations.map( + (locationData, tries) => MapEntry( + locationData.toJSON(), + tries, + ), + ), + }; + } + + static Future create( + final String name, + final List relays, { + List timers = const [], + bool deleteAfterRun = false, + }) async { + FlutterLogs.logInfo( + LOG_TAG, + "Task", + "Creating new task.", + ); + + final secretKey = await generateSecretKey(); + + return Task( + id: uuid.v4(), + name: name, + encryptionPassword: secretKey, + nostrPrivateKey: Keychain.generate().private, + relays: relays, + createdAt: DateTime.now(), + timers: timers, + deleteAfterRun: deleteAfterRun, + ); + } + + TaskCryptography get cryptography => + TaskCryptography(this, _encryptionPassword); + + TaskPublisher get publisher => TaskPublisher(this); + + Future isRunning() async { + final status = await getExecutionStatus(); + + return status != null; + } + + Future?> getExecutionStatus() async { + final rawData = await storage.read(key: taskKey); + + if (rawData == null || rawData == "") { + return null; + } + + final data = jsonDecode(rawData); + + return { + ...data, + "startedAt": DateTime.parse(data["startedAt"]), + }; + } + + Future?> getScheduleStatus() async { + final rawData = await storage.read(key: scheduleKey); + + if (rawData == null || rawData == "") { + return null; + } + + final data = jsonDecode(rawData); + + return { + ...data, + "startedAt": DateTime.parse(data["startedAt"]), + "startsAt": DateTime.parse(data["startsAt"]), + }; + } + + DateTime? nextStartDate({final DateTime? date}) => + findNextStartDate(timers, startDate: date); + + DateTime? nextEndDate() => findNextEndDate(timers); + + bool isInfinite() => + timers.any((timer) => timer.isInfinite()) || timers.isEmpty; + + Future shouldRunNow() async { + final executionStatus = await getExecutionStatus(); + + if (timers.isEmpty) { + return executionStatus != null; + } + + final shouldRunNowBasedOnTimers = + timers.any((timer) => timer.shouldRun(DateTime.now())); + + if (shouldRunNowBasedOnTimers) { + return true; + } + + if (executionStatus != null) { + final earliestNextRun = nextStartDate(date: executionStatus["startedAt"]); + + if (earliestNextRun == null) { + return false; + } + + return (executionStatus["startedAt"] as DateTime) + .isBefore(earliestNextRun); + } + + return false; + } + + Future stopSchedule() async { + await storage.delete(key: scheduleKey); + } + + // Starts the task. This will schedule the task to run at the next expected time. + // You can find out when the task will run by calling `nextStartDate`. + // Returns the next start date of the task OR `null` if the task is not scheduled to run. + Future startSchedule({ + final bool startNowIfNextRunIsUnknown = false, + final DateTime? startDate, + }) async { + final now = startDate ?? DateTime.now(); + DateTime? nextStartDate = this.nextStartDate(date: now); + + if (nextStartDate == null) { + if (startNowIfNextRunIsUnknown) { + nextStartDate = now; + } else { + return null; + } + } + + final isNow = nextStartDate.subtract(SAME_TIME_THRESHOLD).isBefore(now); + + if (isNow) { + await startExecutionImmediately(); + } else { + await stopSchedule(); + + await storage.write( + key: scheduleKey, + value: jsonEncode({ + "startedAt": DateTime.now().toIso8601String(), + "startsAt": nextStartDate.toIso8601String(), + }), + ); + } + + return nextStartDate; + } + + // Starts the schedule tomorrow morning. This should be used when the user manually stops the execution of the task, but + // still wants the task to run at the next expected time. If `startSchedule` is used, the schedule might start, + // immediately, which is not what the user wants. + // Returns the next date the task will run OR `null` if the task is not scheduled to run. + Future startScheduleTomorrow() { + final tomorrow = DateTime.now().add(const Duration(days: 1)); + final nextDate = + DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 6, 0, 0); + + return startSchedule(startDate: nextDate); + } + + // Starts the actual execution of the task. You should only call this if either the user wants to manually start the + // task or if the task is scheduled to run. + Future startExecutionImmediately() async { + FlutterLogs.logInfo( + LOG_TAG, + "Task $id", + "Starting execution of task...", + ); + + await storage.write( + key: taskKey, + value: jsonEncode({ + "startedAt": DateTime.now().toIso8601String(), + }), + ); + + await stopSchedule(); + + for (final timer in timers) { + timer.executionStarted(); + } + + notifyListeners(); + + FlutterLogs.logInfo( + LOG_TAG, + "Task $id", + "Execution of task started!", + ); + } + + // Stops the actual execution of the task. You should only call this if either the user wants to manually stop the + // task or if the task is scheduled to stop. + Future stopExecutionImmediately() async { + FlutterLogs.logInfo( + LOG_TAG, + "Task $id", + "Stopping execution of task...", + ); + + await storage.delete(key: taskKey); + + for (final timer in timers) { + timer.executionStopped(); + } + + notifyListeners(); + + FlutterLogs.logInfo( + LOG_TAG, + "Task $id", + "Execution of task stopped!", + ); + } + + Future update({ + String? name, + Iterable? relays, + Iterable? timers, + bool? deleteAfterRun, + }) async { + if (name != null) { + this.name = name; + } + + if (relays != null) { + // We need to copy the relays as they somehow also get cleared when `this.relays.clear` is called. + final newRelays = [...relays]; + this.relays.clear(); + this.relays.addAll(newRelays); + } + + if (timers != null) { + final newTimers = [...timers]; + this.timers.clear(); + this.timers.addAll(newTimers); + } + + if (deleteAfterRun != null) { + this.deleteAfterRun = deleteAfterRun; + } + + notifyListeners(); + } + + bool get isQuickShare => isInfiniteQuickShare || isFiniteQuickShare; + + bool get isInfiniteQuickShare => deleteAfterRun && timers.isEmpty; + + bool get isFiniteQuickShare => + deleteAfterRun && timers.length == 1 && timers[0] is DurationTimer; + + @override + void dispose() { + _encryptionPassword.destroy(); + + super.dispose(); + } + + TaskView createTaskView_onlyForTesting() => TaskView( + encryptionPassword: _encryptionPassword, + nostrPublicKey: nostrPublicKey, + color: Colors.red, + name: name, + relays: relays, + id: id, + ); +} diff --git a/lib/services/task_service/task_cryptography.dart b/lib/services/task_service/task_cryptography.dart new file mode 100644 index 00000000..2c84af3f --- /dev/null +++ b/lib/services/task_service/task_cryptography.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/utils/cryptography/encrypt.dart'; +import 'package:locus/utils/nostr_fetcher/LocationPointDecrypter.dart'; +import 'package:nostr/nostr.dart'; + +import 'task.dart'; + +class TaskCryptography { + final Task task; + final SecretKey _encryptionPassword; + + TaskCryptography(this.task, this._encryptionPassword); + + Future generateViewKeyContent() async => + jsonEncode({ + "encryptionPassword": await _encryptionPassword.extractBytes(), + "nostrPublicKey": task.nostrPublicKey, + "relays": task.relays, + }); + + Future encrypt(final String message) => + encryptUsingAES(message, _encryptionPassword); + + Future decryptFromNostrMessage(final Message message) => + LocationPointDecrypter( + _encryptionPassword, + ).decryptFromNostrMessage(message); +} \ No newline at end of file diff --git a/lib/services/task_service/task_example.dart b/lib/services/task_service/task_example.dart new file mode 100644 index 00000000..be093c6a --- /dev/null +++ b/lib/services/task_service/task_example.dart @@ -0,0 +1,13 @@ +import '../timers_service.dart'; + +class TaskExample { + final String name; + final List timers; + final bool realtime; + + const TaskExample({ + required this.name, + required this.timers, + this.realtime = false, + }); +} diff --git a/lib/services/task_service/task_publisher.dart b/lib/services/task_service/task_publisher.dart new file mode 100644 index 00000000..f101012e --- /dev/null +++ b/lib/services/task_service/task_publisher.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/api/nostr-events.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/utils/cryptography/encrypt.dart'; +import 'package:locus/utils/cryptography/utils.dart'; +import 'package:locus/utils/location/index.dart'; + +import 'enums.dart'; +import 'task.dart'; + +class TaskPublisher { + final Task task; + + const TaskPublisher(this.task); + + // Generates a link that can be used to retrieve the task + // This link is primarily used for sharing the task to the web app + // Here's the process: + // 1. Generate a random password + // 2. Encrypt the task with the password + // 3. Publish the encrypted task to a random Nostr relay + // 4. Generate a link that contains the password and the Nostr relay ID + Future generateLink( + final String host, { + final void Function(TaskLinkPublishProgress progress)? onProgress, + }) async { + onProgress?.call(TaskLinkPublishProgress.startsSoon); + + final message = await task.cryptography.generateViewKeyContent(); + + onProgress?.call(TaskLinkPublishProgress.encrypting); + + final passwordSecretKey = await generateSecretKey(); + final password = await passwordSecretKey.extractBytes(); + final cipherText = await encryptUsingAES(message, passwordSecretKey); + + onProgress?.call(TaskLinkPublishProgress.publishing); + + final manager = NostrEventsManager( + relays: task.relays, + privateKey: task.nostrPrivateKey, + ); + final publishedEvent = await manager.publishMessage(cipherText, kind: 1001); + + onProgress?.call(TaskLinkPublishProgress.creatingURI); + + final parameters = { + // Password + "p": password, + // Key + "k": task.nostrPublicKey, + // ID + "i": publishedEvent.id, + // Relay + "r": task.relays, + }; + + final fragment = base64Url.encode(jsonEncode(parameters).codeUnits); + final uri = Uri( + scheme: "https", + host: host, + path: "/", + fragment: fragment, + ); + + onProgress?.call(TaskLinkPublishProgress.done); + passwordSecretKey.destroy(); + + return uri.toString(); + } + + Future publishLocation( + final LocationPointService locationPoint, + ) async { + final eventManager = NostrEventsManager.fromTask(task); + + final rawMessage = jsonEncode(locationPoint.toJSON()); + final message = await task.cryptography.encrypt(rawMessage); + + try { + await eventManager.publishMessage(message); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Task ${task.id}", + "Failed to publish location: $error", + ); + + task.outstandingLocations[locationPoint] = 0; + + rethrow; + } + } + + Future publishCurrentPosition() async { + final position = await getCurrentPosition(); + final locationPoint = await LocationPointService.fromPosition(position); + + await publishLocation(locationPoint); + + return locationPoint; + } + + Future publishOutstandingPositions() async { + FlutterLogs.logInfo( + LOG_TAG, + "Task ${task.id}", + "Publishing outstanding locations...", + ); + + // Iterate over point and tries + for (final entry in task.outstandingLocations.entries) { + final locationPoint = entry.key; + final tries = entry.value; + + if (tries >= LOCATION_PUBLISH_MAX_TRIES) { + FlutterLogs.logInfo( + LOG_TAG, + "Task ${task.id}", + "Skipping location point as it has been published too many times.", + ); + + task.outstandingLocations.remove(locationPoint); + + continue; + } + + try { + await publishLocation(locationPoint); + + task.outstandingLocations.remove(locationPoint); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Task ${task.id}", + "Failed to publish outstanding location: $error", + ); + } + } + } +} diff --git a/lib/services/task_service/task_service.dart b/lib/services/task_service/task_service.dart new file mode 100644 index 00000000..68eda81e --- /dev/null +++ b/lib/services/task_service/task_service.dart @@ -0,0 +1,187 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/models/log.dart'; +import 'package:locus/services/log_service.dart' hide KEY, storage; +import 'package:locus/services/task_service/task.dart'; +import 'package:locus/services/timers_service.dart'; + +import 'constants.dart'; + +class TaskService extends ChangeNotifier { + final List _tasks; + + TaskService({ + required List tasks, + }) : _tasks = tasks; + + UnmodifiableListView get tasks => UnmodifiableListView(_tasks); + + static Future restore() async { + final rawTasks = await storage.read(key: KEY); + + if (rawTasks == null) { + return TaskService( + tasks: [], + ); + } + + return TaskService( + tasks: List.from( + List>.from( + jsonDecode(rawTasks), + ).map( + Task.fromJSON, + ), + ).toList(), + ); + } + + Future save() async { + FlutterLogs.logInfo( + LOG_TAG, + "Task Service", + "Saving tasks...", + ); + + // await all `toJson` functions + final data = await Future.wait>( + _tasks.map( + (task) => task.toJSON(), + ), + ); + + await storage.write(key: KEY, value: jsonEncode(data)); + FlutterLogs.logInfo( + LOG_TAG, + "Task Service", + "Saved tasks successfully!", + ); + } + + Task getByID(final String id) { + return _tasks.firstWhere((task) => task.id == id); + } + + void add(Task task) { + _tasks.add(task); + + notifyListeners(); + } + + void remove(final Task task) { + task.stopExecutionImmediately(); + _tasks.remove(task); + + notifyListeners(); + } + + void forceListenerUpdate() { + notifyListeners(); + } + + void update(final Task task) { + final index = _tasks.indexWhere((element) => element.id == task.id); + + _tasks[index] = task; + + notifyListeners(); + save(); + } + + // Does a general check up state of the task. + // Checks if the task should be running / should be deleted etc. + Future checkup(final LogService logService) async { + FlutterLogs.logInfo(LOG_TAG, "Task Service", "Doing checkup..."); + + final tasksToRemove = {}; + + for (final task in tasks) { + final isRunning = await task.isRunning(); + final shouldRun = await task.shouldRunNow(); + + if (task.isInfiniteQuickShare) { + // Infinite quick shares are completely user controlled. + // Nothing to do here. + } else if (task.isFiniteQuickShare) { + final durationTimer = task.timers[0] as DurationTimer; + + // Time is over, remove task. + if (durationTimer.startDate != null && !shouldRun) { + FlutterLogs.logInfo(LOG_TAG, "Task Service", "Removing task."); + + tasksToRemove.add(task); + } + } else { + if ((!task.isInfinite() && task.nextEndDate() == null)) { + FlutterLogs.logInfo(LOG_TAG, "Task Service", "Removing task."); + + tasksToRemove.add(task); + } else if (!shouldRun && isRunning) { + FlutterLogs.logInfo(LOG_TAG, "Task Service", "Stopping task."); + await task.stopExecutionImmediately(); + + await logService.addLog( + Log.taskStatusChanged( + initiator: LogInitiator.system, + taskId: task.id, + taskName: task.name, + active: false, + ), + ); + } else if (shouldRun && !isRunning) { + FlutterLogs.logInfo(LOG_TAG, "Task Service", "Start task."); + await task.startExecutionImmediately(); + + await logService.addLog( + Log.taskStatusChanged( + initiator: LogInitiator.system, + taskId: task.id, + taskName: task.name, + active: true, + ), + ); + } + } + } + + for (final task in tasksToRemove) { + remove(task); + } + + await save(); + + FlutterLogs.logInfo(LOG_TAG, "Task Service", "Checkup done."); + } + + Stream getRunningTasks() async* { + for (final task in tasks) { + if (await task.isRunning()) { + yield task; + } + } + } + + Future hasRunningTasks() async { + for (final task in tasks) { + if (await task.isRunning()) { + return true; + } + } + + return false; + } + + Future hasScheduledTasks() async { + for (final task in tasks) { + if (task.timers.isNotEmpty && !task.deleteAfterRun) { + return true; + } + } + + return false; + } +} diff --git a/lib/services/view_service/alarm_handler.dart b/lib/services/view_service/alarm_handler.dart new file mode 100644 index 00000000..81cc2c1a --- /dev/null +++ b/lib/services/view_service/alarm_handler.dart @@ -0,0 +1,89 @@ +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/services/location_alarm_service/LocationAlarmServiceBase.dart'; +import 'package:locus/services/location_alarm_service/enums.dart'; +import 'package:locus/services/location_fetcher_service/Fetcher.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/nostr_fetcher/NostrSocket.dart'; +import 'package:nostr/nostr.dart'; + +class AlarmHandler { + final TaskView view; + + const AlarmHandler(this.view); + + Future< + (LocationAlarmTriggerType, LocationAlarmServiceBase?, LocationPointService?)> checkAlarm( + final LocationPointService userLocation,) async { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Check View Alarms", + "Checking view ${view.name} from ${view.lastAlarmCheck}...", + ); + + final fetcher = Fetcher(view); + await fetcher.fetchCustom( + Request( + generate64RandomHexChars(), + [ + NostrSocket.createNostrRequestDataFromTask( + view, + from: view.lastAlarmCheck, + ), + ], + ), + ); + final locations = fetcher.sortedLocations; + + view.lastAlarmCheck = DateTime.now(); + + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Check View Alarms", + " -> ${locations.length} locations", + ); + + if (locations.isEmpty) { + FlutterLogs.logInfo( + LOG_TAG, + "Headless Task; Check View Alarms", + " -> No locations", + ); + + return (LocationAlarmTriggerType.no, null, null); + } + + locations.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + LocationPointService oldLocation = locations.first; + + // Iterate over each location but the first one + // Iterating backwards to check the last locations first, + // if we miss an old location, it's not that bad, newer data + // is more important + // Return on first found to not spam the user with multiple alarms + for (final location in locations + .skip(1) + .toList() + .reversed) { + for (final alarm in view.alarms) { + final checkResult = alarm.check( + location, + oldLocation, + userLocation: userLocation, + ); + + if (checkResult == LocationAlarmTriggerType.yes) { + return (LocationAlarmTriggerType.yes, alarm, location); + } else if (checkResult == LocationAlarmTriggerType.maybe) { + return (LocationAlarmTriggerType.maybe, alarm, location); + } + } + + oldLocation = location; + } + + return (LocationAlarmTriggerType.no, null, null); + } +} diff --git a/lib/services/view_service/constants.dart b/lib/services/view_service/constants.dart new file mode 100644 index 00000000..2139fb0e --- /dev/null +++ b/lib/services/view_service/constants.dart @@ -0,0 +1,4 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +const storage = FlutterSecureStorage(); +const KEY = "view_service"; diff --git a/lib/services/view_service/index.dart b/lib/services/view_service/index.dart new file mode 100644 index 00000000..803eb3dc --- /dev/null +++ b/lib/services/view_service/index.dart @@ -0,0 +1,2 @@ +export "view.dart"; +export "view_service.dart"; diff --git a/lib/services/view_service.dart b/lib/services/view_service/view.dart similarity index 53% rename from lib/services/view_service.dart rename to lib/services/view_service/view.dart index ec18fbd2..34e12fed 100644 --- a/lib/services/view_service.dart +++ b/lib/services/view_service/view.dart @@ -5,37 +5,26 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:cryptography/cryptography.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_logs/flutter_logs.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:locus/api/nostr-fetch.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/location_fetcher_service/Fetcher.dart'; +import 'package:locus/services/task_service/index.dart'; +import 'package:locus/services/view_service/alarm_handler.dart'; +import 'package:locus/services/view_service/index.dart'; import 'package:locus/utils/cryptography/decrypt.dart'; +import 'package:locus/utils/nostr_fetcher/LocationPointDecrypter.dart'; +import 'package:locus/utils/nostr_fetcher/NostrSocket.dart'; import 'package:nostr/nostr.dart'; import 'package:uuid/uuid.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -import '../api/get-locations.dart' as get_locations_api; -import '../constants/values.dart'; -import 'location_alarm_service.dart'; -import 'location_base.dart'; -import 'location_point_service.dart'; - -const storage = FlutterSecureStorage(); -const KEY = "view_service"; -class ViewServiceLinkParameters { - final SecretKey password; - final String nostrPublicKey; - final String nostrMessageID; - final List relays; - - const ViewServiceLinkParameters({ - required this.password, - required this.nostrPublicKey, - required this.nostrMessageID, - required this.relays, - }); -} +import '../../constants/values.dart'; +import '../location_alarm_service/GeoLocationAlarm.dart'; +import '../location_alarm_service/LocationAlarmServiceBase.dart'; +import '../location_alarm_service/ProximityLocationAlarm.dart'; +import '../location_alarm_service/enums.dart'; +import '../location_point_service.dart'; +import '../task_service/mixins.dart'; class TaskView extends ChangeNotifier with LocationBase { final SecretKey _encryptionPassword; @@ -60,17 +49,20 @@ class TaskView extends ChangeNotifier with LocationBase { String? id, DateTime? lastAlarmCheck, List? alarms, - }) : _encryptionPassword = encryptionPassword, + }) + : _encryptionPassword = encryptionPassword, alarms = alarms ?? [], lastAlarmCheck = lastAlarmCheck ?? DateTime.now(), id = id ?? const Uuid().v4(); + AlarmHandler get alarmHandler => AlarmHandler(this); + static ViewServiceLinkParameters parseLink(final String url) { final uri = Uri.parse(url); final fragment = uri.fragment; final rawParameters = - const Utf8Decoder().convert(base64Url.decode(fragment)); + const Utf8Decoder().convert(base64Url.decode(fragment)); final parameters = jsonDecode(rawParameters); return ViewServiceLinkParameters( @@ -84,9 +76,10 @@ class TaskView extends ChangeNotifier with LocationBase { ); } - factory TaskView.fromJSON(final Map json) => TaskView( + factory TaskView.fromJSON(final Map json) => + TaskView( encryptionPassword: - SecretKey(List.from(json["encryptionPassword"])), + SecretKey(List.from(json["encryptionPassword"])), nostrPublicKey: json["nostrPublicKey"], relays: List.from(json["relays"]), name: json["name"] ?? "Unnamed Task", @@ -98,8 +91,10 @@ class TaskView extends ChangeNotifier with LocationBase { .firstWhere((element) => element.name == alarm["_IDENTIFIER"]); switch (identifier) { - case LocationAlarmType.radiusBasedRegion: - return RadiusBasedRegionLocationAlarm.fromJSON(alarm); + case LocationAlarmType.geo: + return GeoLocationAlarm.fromJSON(alarm); + case LocationAlarmType.proximity: + return ProximityLocationAlarm.fromJSON(alarm); } }), ), @@ -114,10 +109,8 @@ class TaskView extends ChangeNotifier with LocationBase { : Colors.primaries[Random().nextInt(Colors.primaries.length)], ); - static Future fetchFromNostr( - final AppLocalizations l10n, - final ViewServiceLinkParameters parameters, - ) async { + static Future fetchFromNostr(final AppLocalizations l10n, + final ViewServiceLinkParameters parameters,) async { final completer = Completer(); final request = Request(generate64RandomHexChars(), [ @@ -159,7 +152,7 @@ class TaskView extends ChangeNotifier with LocationBase { relays: List.from(data['relays']), name: l10n.longFormattedDate(DateTime.now()), color: - Colors.primaries[Random().nextInt(Colors.primaries.length)], + Colors.primaries[Random().nextInt(Colors.primaries.length)], ), ); } catch (error) { @@ -206,8 +199,7 @@ class TaskView extends ChangeNotifier with LocationBase { }; } - Future validate( - final AppLocalizations l10n, { + Future validate(final AppLocalizations l10n, { required final TaskService taskService, required final ViewService viewService, }) async { @@ -216,14 +208,14 @@ class TaskView extends ChangeNotifier with LocationBase { } final sameTask = taskService.tasks.firstWhereOrNull( - (element) => element.nostrPublicKey == nostrPublicKey); + (element) => element.nostrPublicKey == nostrPublicKey); if (sameTask != null) { return l10n.taskImport_error_sameTask(sameTask.name); } final sameView = viewService.views.firstWhereOrNull( - (element) => element.nostrPublicKey == nostrPublicKey); + (element) => element.nostrPublicKey == nostrPublicKey); if (sameView != null) { return l10n.taskImport_error_sameView(sameView.name); @@ -232,37 +224,6 @@ class TaskView extends ChangeNotifier with LocationBase { return null; } - @override - VoidCallback getLocations({ - required void Function(LocationPointService) onLocationFetched, - required void Function() onEnd, - final VoidCallback? onEmptyEnd, - int? limit, - DateTime? from, - }) => - get_locations_api.getLocations( - encryptionPassword: _encryptionPassword, - nostrPublicKey: nostrPublicKey, - relays: relays, - onLocationFetched: onLocationFetched, - onEnd: onEnd, - onEmptyEnd: onEmptyEnd, - from: from, - limit: limit, - ); - - Future> getLocationsAsFuture({ - int? limit, - DateTime? from, - }) => - get_locations_api.getLocationsAsFuture( - encryptionPassword: _encryptionPassword, - nostrPublicKey: nostrPublicKey, - relays: relays, - from: from, - limit: limit, - ); - @override void dispose() { _encryptionPassword.destroy(); @@ -270,50 +231,6 @@ class TaskView extends ChangeNotifier with LocationBase { super.dispose(); } - Future checkAlarm({ - required final void Function( - LocationAlarmServiceBase alarm, - LocationPointService previousLocation, - LocationPointService nextLocation) - onTrigger, - required final void Function( - LocationAlarmServiceBase alarm, - LocationPointService previousLocation, - LocationPointService nextLocation) - onMaybeTrigger, - }) async { - final locations = await getLocationsAsFuture( - from: lastAlarmCheck, - ); - - lastAlarmCheck = DateTime.now(); - - if (locations.isEmpty) { - return; - } - - locations.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - - LocationPointService oldLocation = locations.first; - - // Iterate over each location but the first one - for (final location in locations.skip(1)) { - for (final alarm in alarms) { - final checkResult = alarm.check(oldLocation, location); - - if (checkResult == LocationAlarmTriggerType.yes) { - onTrigger(alarm, oldLocation, location); - break; - } else if (checkResult == LocationAlarmTriggerType.maybe) { - onMaybeTrigger(alarm, oldLocation, location); - break; - } - } - - oldLocation = location; - } - } - void addAlarm(final LocationAlarmServiceBase alarm) { alarms.add(alarm); notifyListeners(); @@ -323,75 +240,9 @@ class TaskView extends ChangeNotifier with LocationBase { alarms.remove(alarm); notifyListeners(); } -} - -class ViewService extends ChangeNotifier { - final List _views; - ViewService({ - required List views, - }) : _views = views; - - UnmodifiableListView get views => UnmodifiableListView(_views); - - UnmodifiableListView get viewsWithAlarms => - UnmodifiableListView(_views.where((view) => view.alarms.isNotEmpty)); - - TaskView getViewById(final String id) => - _views.firstWhere((view) => view.id == id); - - static Future restore() async { - final rawViews = await storage.read(key: KEY); - - if (rawViews == null) { - return ViewService( - views: [], - ); - } - - return ViewService( - views: List.from( - List>.from( - jsonDecode(rawViews), - ).map( - TaskView.fromJSON, - ), - ).toList(), - ); - } - - Future save() async { - final data = jsonEncode( - List>.from( - await Future.wait( - _views.map( - (view) => view.toJSON(), - ), - ), - ), - ); - - await storage.write(key: KEY, value: data); - } - - void add(final TaskView view) { - _views.add(view); - - notifyListeners(); - } - - void remove(final TaskView view) { - _views.remove(view); - - notifyListeners(); - } - - Future update(final TaskView view) async { - final index = _views.indexWhere((element) => element.id == view.id); - - _views[index] = view; - - notifyListeners(); - await save(); - } + Future decryptFromNostrMessage(final Message message) => + LocationPointDecrypter( + _encryptionPassword, + ).decryptFromNostrMessage(message); } diff --git a/lib/services/view_service/view_service.dart b/lib/services/view_service/view_service.dart new file mode 100644 index 00000000..ade252d1 --- /dev/null +++ b/lib/services/view_service/view_service.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:cryptography/cryptography.dart'; +import 'package:flutter/material.dart'; + +import 'view.dart'; +import 'constants.dart'; + +class ViewServiceLinkParameters { + final SecretKey password; + final String nostrPublicKey; + final String nostrMessageID; + final List relays; + + const ViewServiceLinkParameters({ + required this.password, + required this.nostrPublicKey, + required this.nostrMessageID, + required this.relays, + }); +} + +class ViewService extends ChangeNotifier { + final List _views; + + ViewService({ + required List views, + }) : _views = views; + + UnmodifiableListView get views => UnmodifiableListView(_views); + + UnmodifiableListView get viewsWithAlarms => + UnmodifiableListView(_views.where((view) => view.alarms.isNotEmpty)); + + TaskView getViewById(final String id) => + _views.firstWhere((view) => view.id == id); + + static Future restore() async { + final rawViews = await storage.read(key: KEY); + + if (rawViews == null) { + return ViewService( + views: [], + ); + } + + return ViewService( + views: List.from( + List>.from( + jsonDecode(rawViews), + ).map( + TaskView.fromJSON, + ), + ).toList(), + ); + } + + Future save() async { + final data = jsonEncode( + List>.from( + await Future.wait( + _views.map( + (view) => view.toJSON(), + ), + ), + ), + ); + + await storage.write(key: KEY, value: data); + } + + void add(final TaskView view) { + _views.add(view); + + notifyListeners(); + } + + void remove(final TaskView view) { + _views.remove(view); + + notifyListeners(); + } + + Future update(final TaskView view) async { + final index = _views.indexWhere((element) => element.id == view.id); + + _views[index] = view; + + notifyListeners(); + await save(); + } +} diff --git a/lib/utils/PageRoute.dart b/lib/utils/PageRoute.dart index 3ada1377..2a032f00 100644 --- a/lib/utils/PageRoute.dart +++ b/lib/utils/PageRoute.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:provider/provider.dart'; -import '../services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; class NativePageRoute extends MaterialPageRoute { final BuildContext context; diff --git a/lib/utils/access-deeply-nested-key.dart b/lib/utils/access-deeply-nested-key.dart new file mode 100644 index 00000000..51c73dd5 --- /dev/null +++ b/lib/utils/access-deeply-nested-key.dart @@ -0,0 +1,19 @@ +T? accessDeeplyNestedKey(final Map obj, final String path) { + dynamic result = obj; + + for (final subPath in path.split(".")) { + if (result is List) { + final index = int.tryParse(subPath)!; + + result = result[index]; + } else if (result.containsKey(subPath)) { + result = result[subPath]; + } else { + return null; + } + } + + return result as T; +} + +const adnk = accessDeeplyNestedKey; diff --git a/lib/utils/date.dart b/lib/utils/date.dart index 214c64b1..35276c18 100644 --- a/lib/utils/date.dart +++ b/lib/utils/date.dart @@ -1,4 +1,6 @@ // Creates a DateTime that represents the given weekday in the year 2004. Primarily only used for formatting weekdays. +import 'package:intl/intl.dart'; + DateTime createDateFromWeekday(final int day) => DateTime( 2004, 0, @@ -13,3 +15,24 @@ extension DateTimeExtension on DateTime { bool isSameDay(final DateTime other) => year == other.year && month == other.month && day == other.day; } + +String formatDateTimeHumanReadable( + final DateTime dateTime, [ + final DateTime? comparison, +]) { + final compareValue = comparison ?? DateTime.now(); + + if (dateTime.year != compareValue.year) { + return DateFormat.yMMMMd().add_Hms().format(dateTime); + } + + if (dateTime.month != compareValue.month) { + return DateFormat.MMMMd().add_Hms().format(dateTime); + } + + if (dateTime.day != compareValue.day) { + return DateFormat.MMMd().add_Hms().format(dateTime); + } + + return DateFormat.Hms().format(dateTime); +} diff --git a/lib/utils/device/battery.dart b/lib/utils/device/battery.dart new file mode 100644 index 00000000..361ce71e --- /dev/null +++ b/lib/utils/device/battery.dart @@ -0,0 +1,10 @@ +import 'package:battery_plus/battery_plus.dart'; + +Future isBatterySaveModeEnabled() async { + try { + final value = await Battery().isInBatterySaveMode; + return value; + } catch (_) { + return false; + } +} diff --git a/lib/utils/device/index.dart b/lib/utils/device/index.dart new file mode 100644 index 00000000..fb178b30 --- /dev/null +++ b/lib/utils/device/index.dart @@ -0,0 +1,2 @@ +export "battery.dart"; +export "info.dart"; diff --git a/lib/utils/device.dart b/lib/utils/device/info.dart similarity index 100% rename from lib/utils/device.dart rename to lib/utils/device/info.dart diff --git a/lib/utils/helper_sheet.dart b/lib/utils/helper_sheet.dart index 16accfcd..c6c339d0 100644 --- a/lib/utils/helper_sheet.dart +++ b/lib/utils/helper_sheet.dart @@ -2,7 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/constants/spacing.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:locus/utils/theme.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; diff --git a/lib/utils/import_export_handler.dart b/lib/utils/import_export_handler.dart index a8ffe564..ce3d65d9 100644 --- a/lib/utils/import_export_handler.dart +++ b/lib/utils/import_export_handler.dart @@ -1,18 +1,18 @@ import 'package:locus/constants/values.dart'; -import 'package:locus/services/settings_service.dart'; -import 'package:locus/services/task_service.dart'; -import 'package:locus/services/view_service.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'; -Future> exportToJSON( - final TaskService taskService, - final ViewService viewService, - final SettingsService settings, -) async => +Future> exportToJSON(final TaskService taskService, + final ViewService viewService, + final SettingsService settings,) async => { "version": 1, "data": { - "tasks": await Future.wait(taskService.tasks.map((task) => task.toJSON()).toList()), - "views": await Future.wait(viewService.views.map((view) => view.toJSON()).toList()), + "tasks": await Future.wait( + taskService.tasks.map((task) => task.toJSON()).toList()), + "views": await Future.wait( + viewService.views.map((view) => view.toJSON()).toList()), "settings": settings.toJSON(), } }; diff --git a/lib/utils/map.dart b/lib/utils/map.dart new file mode 100644 index 00000000..f0e2f1c1 --- /dev/null +++ b/lib/utils/map.dart @@ -0,0 +1,4 @@ +import 'dart:math'; + +double getZoomLevelForRadius(final double radiusInMeters) => + 18 - log(radiusInMeters / 35) / log(2); diff --git a/lib/utils/nostr_fetcher/BasicNostrFetchSocket.dart b/lib/utils/nostr_fetcher/BasicNostrFetchSocket.dart new file mode 100644 index 00000000..c78922ae --- /dev/null +++ b/lib/utils/nostr_fetcher/BasicNostrFetchSocket.dart @@ -0,0 +1,55 @@ +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/utils/nostr_fetcher/Socket.dart'; +import 'package:nostr/nostr.dart'; + +abstract class BasicNostrFetchSocket extends Socket { + BasicNostrFetchSocket({ + required final String relay, + super.timeout, + }) : super(uri: ensureProtocol(relay)); + + static ensureProtocol(final String relay) { + if (!relay.startsWith("ws://") && !relay.startsWith("wss://")) { + return "wss://$relay"; + } + + return relay; + } + + @override + void onEvent(final event) { + final message = Message.deserialize(event); + + FlutterLogs.logInfo( + LOG_TAG, + "Nostr Socket", + "New event received: ${message.type}", + ); + + switch (message.type) { + case "EOSE": + FlutterLogs.logInfo( + LOG_TAG, + "Nostr Socket", + " -> It is: End of stream event; Closing socket.", + ); + + onEndOfStream(); + break; + case "EVENT": + FlutterLogs.logInfo( + LOG_TAG, + "Nostr Socket", + " -> It is: Event; Passing down.", + ); + + onNostrEvent(message); + break; + } + } + + void onEndOfStream(); + + void onNostrEvent(final Message message); +} diff --git a/lib/utils/nostr_fetcher/LocationPointDecrypter.dart b/lib/utils/nostr_fetcher/LocationPointDecrypter.dart new file mode 100644 index 00000000..2e661173 --- /dev/null +++ b/lib/utils/nostr_fetcher/LocationPointDecrypter.dart @@ -0,0 +1,18 @@ +import 'package:cryptography/cryptography.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:nostr/nostr.dart'; + +class LocationPointDecrypter { + final SecretKey _password; + + const LocationPointDecrypter(this._password); + + Future decryptFromNostrMessage( + final Message message, + ) async { + return LocationPointService.fromEncrypted( + message.message.content, + _password, + ); + } +} diff --git a/lib/utils/nostr_fetcher/NostrSocket.dart b/lib/utils/nostr_fetcher/NostrSocket.dart new file mode 100644 index 00000000..d8e77b3f --- /dev/null +++ b/lib/utils/nostr_fetcher/NostrSocket.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/task_service/mixins.dart'; +import 'package:locus/utils/nostr_fetcher/BasicNostrFetchSocket.dart'; +import 'package:locus/utils/nostr_fetcher/Socket.dart'; +import 'package:nostr/nostr.dart'; +import 'package:queue/queue.dart'; + +class NostrSocket extends BasicNostrFetchSocket { + NostrSocket({ + required super.relay, + super.timeout, + required this.decryptMessage, + final int decryptionParallelProcesses = 4, + }) : _decryptionQueue = + Queue(parallel: decryptionParallelProcesses, timeout: timeout); + + int _processesInQueue = 0; + final StreamController _controller = + StreamController(); + + Stream get stream => _controller.stream; + + late final Queue _decryptionQueue; + + late final Future Function(Message) decryptMessage; + + void _finish() async { + FlutterLogs.logInfo( + LOG_TAG, + "Nostr Socket", + "Closing everything...", + ); + + if (_processesInQueue > 0) { + FlutterLogs.logInfo( + LOG_TAG, + "Nostr Socket", + " -> Waiting for $_processesInQueue decryption processes to finish...", + ); + + await _decryptionQueue.onComplete; + } + + _decryptionQueue.dispose(); + _controller.close(); + + closeConnection(); + + FlutterLogs.logInfo( + LOG_TAG, + "Nostr Socket", + "Closing everything... Done!", + ); + } + + @override + void onEndOfStream() async { + _finish(); + } + + @override + void onNostrEvent(final Message message) { + _decryptionQueue.add(() => _handleDecryption(message)); + } + + Future _handleDecryption(final Message message) async { + _processesInQueue++; + FlutterLogs.logInfo( + LOG_TAG, + "Nostr Socket", + "Trying to decrypt message...", + ); + + try { + final location = await decryptMessage(message); + + FlutterLogs.logInfo( + LOG_TAG, + "Nostr Socket", + " -> Decryption successful!", + ); + + _controller.add(location); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Nostr Socket", + " -> Decryption failed: $error", + ); + } finally { + _processesInQueue--; + } + } + + @override + void onError(error) { + if (error == TIMEOUT_ERROR) { + _finish(); + return; + } + + FlutterLogs.logError( + LOG_TAG, + "Nostr Socket", + "Error while fetching events from $uri: $error; Closing everything.", + ); + + _decryptionQueue.cancel(); + _decryptionQueue.dispose(); + _controller.addError(error); + _controller.close(); + } + + static Filter createNostrRequestData({ + final List? kinds, + final int? limit, + final DateTime? from, + final DateTime? until, + final List? authors, + }) => + Filter( + kinds: kinds, + limit: limit, + authors: authors ?? [], + since: + from == null ? null : (from.millisecondsSinceEpoch / 1000).floor(), + until: until == null + ? null + : (until.millisecondsSinceEpoch / 1000).floor(), + ); + + static Filter createNostrRequestDataFromTask( + final LocationBase task, { + final int? limit, + final DateTime? from, + final DateTime? until, + }) => + Filter( + kinds: [1000], + authors: [task.nostrPublicKey], + limit: limit, + since: + from == null ? null : (from.millisecondsSinceEpoch / 1000).floor(), + until: until == null + ? null + : (until.millisecondsSinceEpoch / 1000).floor(), + ); +} diff --git a/lib/utils/nostr_fetcher/Socket.dart b/lib/utils/nostr_fetcher/Socket.dart new file mode 100644 index 00000000..b7674994 --- /dev/null +++ b/lib/utils/nostr_fetcher/Socket.dart @@ -0,0 +1,110 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:locus/constants/values.dart'; + +const TIMEOUT_ERROR = "Timeout reached"; + +abstract class Socket { + final String uri; + final Duration timeout; + + Socket({ + required this.uri, + this.timeout = const Duration(seconds: 10), + }); + + WebSocket? _socket; + + bool get isConnected => _socket != null; + + Timer? _timeoutTimer; + + final Completer _completeListeners = Completer(); + + Future get onComplete => _completeListeners.future; + + void closeConnection() { + _socket?.close(); + _socket = null; + _timeoutTimer?.cancel(); + _completeListeners.complete(); + } + + void _abort(final dynamic error) { + FlutterLogs.logError( + LOG_TAG, + "Socket", + "Error while fetching events from $uri: $error", + ); + + onError(error); + } + + void _resetTimer() { + _timeoutTimer?.cancel(); + + _timeoutTimer = Timer(timeout, () { + FlutterLogs.logInfo( + LOG_TAG, + "Socket", + "Timeout reached, closing stream.", + ); + + _abort(TIMEOUT_ERROR); + }); + } + + void addData(final dynamic data) { + assert(isConnected, + "Socket is not connected. Make sure to call `connect` first."); + + _socket!.add(data); + } + + void _registerSocket() { + FlutterLogs.logInfo( + LOG_TAG, + "Socket", + "Socket connected to $uri. Listening now.", + ); + _socket!.listen((event) { + if (!isConnected) { + closeConnection(); + return; + } + + _resetTimer(); + + onEvent(event); + }); + } + + Future connect() async { + if (isConnected) { + FlutterLogs.logInfo( + LOG_TAG, + "Socket", + "Socket already exists, no action taken.", + ); + + return; + } + + _resetTimer(); + FlutterLogs.logInfo( + LOG_TAG, + "Socket", + "Connecting to $uri...", + ); + + _socket = await WebSocket.connect(uri); + _registerSocket(); + } + + void onError(final dynamic error); + + void onEvent(final dynamic event); +} diff --git a/lib/utils/permissions/mixins.dart b/lib/utils/permissions/mixins.dart index 2c5a1de5..0f3890c4 100644 --- a/lib/utils/permissions/mixins.dart +++ b/lib/utils/permissions/mixins.dart @@ -4,7 +4,7 @@ import 'package:nearby_connections/nearby_connections.dart'; import 'has-granted.dart'; -abstract class BluetoothPermissionMixin { +mixin BluetoothPermissionMixin { bool hasGrantedBluetoothPermission = false; setState(VoidCallback fn); diff --git a/lib/utils/repeatedly-check.dart b/lib/utils/repeatedly-check.dart new file mode 100644 index 00000000..91533937 --- /dev/null +++ b/lib/utils/repeatedly-check.dart @@ -0,0 +1,19 @@ +Future repeatedlyCheckForSuccess( + final Future Function() check, [ + final Duration interval = const Duration(milliseconds: 500), + final Duration timeout = const Duration(seconds: 30), +]) async { + final stopwatch = Stopwatch()..start(); + + while (stopwatch.elapsed < timeout) { + final result = await check(); + + if (result != null) { + return result; + } + + await Future.delayed(interval); + } + + return; +} diff --git a/lib/utils/task.dart b/lib/utils/task.dart index 392406e0..1ecb7916 100644 --- a/lib/utils/task.dart +++ b/lib/utils/task.dart @@ -3,11 +3,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; -import '../services/settings_service.dart'; -import '../services/task_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; mixin TaskLinkGenerationMixin { @@ -35,7 +35,7 @@ mixin TaskLinkGenerationMixin { final l10n = AppLocalizations.of(context); final settings = context.read(); - final url = await task.generateLink( + final url = await task.publisher.generateLink( settings.getServerHost(), onProgress: (progress) { if (taskLinkGenerationSnackbar != null) { diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index 2290fdb4..055cac74 100644 --- a/lib/utils/theme.dart +++ b/lib/utils/theme.dart @@ -4,7 +4,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/constants/colors.dart'; import 'package:locus/constants/spacing.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:locus/utils/PageRoute.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/AddressFetcher.dart b/lib/widgets/AddressFetcher.dart index 34c4489f..fd4be908 100644 --- a/lib/widgets/AddressFetcher.dart +++ b/lib/widgets/AddressFetcher.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_logs/flutter_logs.dart'; import 'package:locus/constants/values.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:provider/provider.dart'; class AddressFetcher extends StatefulWidget { diff --git a/lib/widgets/BentoGridElement.dart b/lib/widgets/BentoGridElement.dart index cce54948..9d5510c6 100644 --- a/lib/widgets/BentoGridElement.dart +++ b/lib/widgets/BentoGridElement.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/extensions/string.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:provider/provider.dart'; enum BentoType { diff --git a/lib/widgets/BottomSheetFilterBuilder.dart b/lib/widgets/BottomSheetFilterBuilder.dart index ee3e372b..b27a74c0 100644 --- a/lib/widgets/BottomSheetFilterBuilder.dart +++ b/lib/widgets/BottomSheetFilterBuilder.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/constants/spacing.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:provider/provider.dart'; dynamic defaultExtractor(dynamic element) => element; @@ -31,10 +31,12 @@ class BottomSheetFilterBuilder extends StatefulWidget { }) : super(key: key); @override - State createState() => _BottomSheetFilterBuilderState(); + State createState() => + _BottomSheetFilterBuilderState(); } -class _BottomSheetFilterBuilderState extends State { +class _BottomSheetFilterBuilderState + extends State { List _elements = []; @override @@ -89,7 +91,9 @@ class _BottomSheetFilterBuilderState extends State decoration: InputDecoration( hintText: l10n.searchLabel, prefixIcon: Icon( - settings.isMIUI() ? CupertinoIcons.search : Icons.search_rounded, + settings.isMIUI() + ? CupertinoIcons.search + : Icons.search_rounded, ), suffixIcon: IconButton( icon: const Icon(Icons.clear), diff --git a/lib/widgets/CompassMapAction.dart b/lib/widgets/CompassMapAction.dart new file mode 100644 index 00000000..3affd2e9 --- /dev/null +++ b/lib/widgets/CompassMapAction.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/utils/theme.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; + +import '../constants/spacing.dart'; +import 'Paper.dart'; +import 'PlatformFlavorWidget.dart'; + +class CompassMapAction extends StatefulWidget { + final double dimension; + final double tooltipSpacing; + final VoidCallback onAlignNorth; + final MapController mapController; + + const CompassMapAction({ + super.key, + this.dimension = 50.0, + this.tooltipSpacing = 10.0, + required this.onAlignNorth, + required this.mapController, + }); + + @override + State createState() => _CompassMapActionState(); +} + +class _CompassMapActionState extends State + with TickerProviderStateMixin { + late final AnimationController rotationController; + late Animation rotationAnimation; + + late final StreamSubscription _mapEventSubscription; + + @override + void initState() { + super.initState(); + + rotationController = + AnimationController(vsync: this, duration: Duration.zero); + rotationAnimation = Tween( + begin: 0, + end: 2 * pi, + ).animate(rotationController); + + _mapEventSubscription = + widget.mapController.mapEventStream.listen(updateRotation); + } + + @override + void dispose() { + _mapEventSubscription.cancel(); + + super.dispose(); + } + + void updateRotation(final MapEvent event) { + if (event is MapEventRotate) { + rotationController.animateTo( + ((event.targetRotation % 360) / 360), + duration: Duration.zero, + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final shades = getPrimaryColorShades(context); + + return Tooltip( + message: l10n.mapAction_alignNorth, + preferBelow: false, + margin: EdgeInsets.only(bottom: widget.tooltipSpacing), + child: SizedBox.square( + dimension: widget.dimension, + child: Center( + child: AnimatedBuilder( + animation: rotationAnimation, + builder: (context, _) { + final degrees = rotationAnimation.value * 180 / pi; + final isNorth = (degrees % 360).abs() < 1; + + return PlatformWidget( + material: (context, _) => Paper( + width: null, + borderRadius: BorderRadius.circular(HUGE_SPACE), + padding: EdgeInsets.zero, + child: IconButton( + color: isNorth ? shades[200] : shades[400], + icon: Transform.rotate( + angle: rotationAnimation.value, + child: PlatformFlavorWidget( + material: (context, _) => Transform.rotate( + angle: -pi / 4, + child: const Icon(MdiIcons.compass), + ), + cupertino: (context, _) => + const Icon(CupertinoIcons.location_north_fill), + ), + ), + onPressed: widget.onAlignNorth, + ), + ), + cupertino: (context, _) => CupertinoButton( + color: isNorth ? shades[200] : shades[400], + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(HUGE_SPACE), + onPressed: widget.onAlignNorth, + child: AnimatedBuilder( + animation: rotationAnimation, + builder: (context, child) => Transform.rotate( + angle: rotationAnimation.value, + child: child, + ), + child: const Icon(CupertinoIcons.location_north_fill), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/EmptyLocationsThresholdScreen.dart b/lib/widgets/EmptyLocationsThresholdScreen.dart deleted file mode 100644 index 376a6631..00000000 --- a/lib/widgets/EmptyLocationsThresholdScreen.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'dart:convert'; -import 'dart:math' as math; - -import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:locus/constants/spacing.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -import '../utils/theme.dart'; - -// Congratulations, you found the 3ast3r 3gg! 🎉 -class EmptyLocationsThresholdScreen extends StatefulWidget { - const EmptyLocationsThresholdScreen({super.key}); - - @override - State createState() => - _EmptyLocationsThresholdScreenState(); -} - -class _EmptyLocationsThresholdScreenState - extends State with TickerProviderStateMixin { - late final AnimationController controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 3), - ); - late final AudioPlayer player; - - @override - void initState() { - super.initState(); - - player = AudioPlayer(); - - player.play(AssetSource("bunny.mp3")).then((_) => controller.repeat()); - } - - @override - void dispose() { - player.dispose(); - controller.dispose(); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(MEDIUM_SPACE), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedBuilder( - animation: controller, - builder: (_, child) => Transform.rotate( - angle: controller.value * 2 * math.pi, - child: child, - ), - child: Icon( - context.platformIcons.help, - size: 120, - ), - ), - const SizedBox(height: MEDIUM_SPACE), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.volume_up_rounded, - size: getCaptionTextStyle(context).fontSize, - color: getCaptionTextStyle(context).color, - ), - const SizedBox(width: TINY_SPACE), - Text( - l10n.increaseVolume, - style: getCaptionTextStyle(context), - ), - ], - ), - const SizedBox(height: LARGE_SPACE), - Text( - l10n.locationFetchEmptyError, - style: getBodyTextTextStyle(context), - ), - const SizedBox(height: MEDIUM_SPACE), - Text( - l10n.bunny_unavailable, - style: getBodyTextTextStyle(context), - ), - const SizedBox(height: LARGE_SPACE), - PlatformTextButton( - child: Text(l10n.bunny_unavailable_action), - onPressed: () => launchUrlString( - const Utf8Decoder().convert( - const Base64Decoder().convert( - "aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj16UEdmNGxpTy1LUQ==", - ), - ), - mode: LaunchMode.externalApplication, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/FABOpenContainer.dart b/lib/widgets/FABOpenContainer.dart index f3750b6f..484ca2e7 100644 --- a/lib/widgets/FABOpenContainer.dart +++ b/lib/widgets/FABOpenContainer.dart @@ -1,6 +1,6 @@ import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:locus/utils/color.dart'; import 'package:locus/utils/theme.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/GoToMyLocationMapAction.dart b/lib/widgets/GoToMyLocationMapAction.dart new file mode 100644 index 00000000..cc31fcf2 --- /dev/null +++ b/lib/widgets/GoToMyLocationMapAction.dart @@ -0,0 +1,114 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:locus/utils/theme.dart'; + +import '../constants/spacing.dart'; +import 'Paper.dart'; + +class GoToMyLocationMapAction extends StatefulWidget { + final double dimension; + final double tooltipSpacing; + final VoidCallback onGoToMyLocation; + final bool animate; + + const GoToMyLocationMapAction({ + this.dimension = 50.0, + this.tooltipSpacing = 10.0, + this.animate = false, + required this.onGoToMyLocation, + super.key, + }); + + @override + State createState() => + _GoToMyLocationMapActionState(); +} + +class _GoToMyLocationMapActionState extends State + with SingleTickerProviderStateMixin { + late final AnimationController rotationController = + AnimationController(vsync: this, duration: 2.seconds); + + void _updateAnimation() { + if (widget.animate) { + rotationController.repeat(); + } else { + rotationController.reset(); + } + } + + @override + void initState() { + super.initState(); + + _updateAnimation(); + } + + @override + void didUpdateWidget(covariant GoToMyLocationMapAction oldWidget) { + super.didUpdateWidget(oldWidget); + + _updateAnimation(); + } + + @override + void dispose() { + rotationController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final shades = getPrimaryColorShades(context); + + return Tooltip( + message: l10n.mapAction_goToCurrentPosition, + preferBelow: false, + margin: EdgeInsets.only(bottom: widget.tooltipSpacing), + child: SizedBox.square( + dimension: widget.dimension, + child: Center( + child: PlatformWidget( + material: (context, _) => Paper( + width: null, + borderRadius: BorderRadius.circular(HUGE_SPACE), + padding: EdgeInsets.zero, + child: IconButton( + color: shades[400], + icon: AnimatedBuilder( + animation: rotationController, + builder: (context, child) => Transform.rotate( + angle: rotationController.value * 2 * pi, + child: child, + ), + child: const Icon(Icons.my_location), + ), + onPressed: widget.onGoToMyLocation, + ), + ), + cupertino: (context, _) => CupertinoButton( + color: shades[400], + padding: EdgeInsets.zero, + onPressed: widget.onGoToMyLocation, + child: AnimatedBuilder( + animation: rotationController, + builder: (context, child) => Transform.rotate( + angle: rotationController.value * 2 * pi, + child: child, + ), + child: const Icon(Icons.my_location), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/LocationsMap.dart b/lib/widgets/LocationsMap.dart index ff4fea5a..afa93ed1 100644 --- a/lib/widgets/LocationsMap.dart +++ b/lib/widgets/LocationsMap.dart @@ -8,13 +8,12 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_marker_popup/flutter_map_marker_popup.dart'; import 'package:geolocator/geolocator.dart'; import 'package:intl/intl.dart'; - import 'package:latlong2/latlong.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:locus/utils/location/get-fallback-location.dart'; +import 'package:locus/utils/location/index.dart'; import 'package:locus/utils/permissions/has-granted.dart'; import 'package:locus/widgets/Paper.dart'; -import 'package:locus/utils/location/index.dart'; import 'package:provider/provider.dart'; import '../constants/values.dart'; @@ -367,13 +366,13 @@ class _LocationsMapState extends State { ); case MapProvider.openStreetMap: return LocusFlutterMap( - options: MapOptions( + flutterMapOptions: MapOptions( center: getInitialPosition() ?? getFallbackLocation(context), zoom: widget.initialZoomLevel, maxZoom: 18, ), - mapController: flutterMapController, - children: [ + flutterMapController: flutterMapController, + flutterChildren: [ if (widget.circles.isNotEmpty) AnimatedOpacity( duration: const Duration(milliseconds: 300), diff --git a/lib/widgets/LocusFlutterMap.dart b/lib/widgets/LocusFlutterMap.dart index 4b3efa97..9842bfe2 100644 --- a/lib/widgets/LocusFlutterMap.dart +++ b/lib/widgets/LocusFlutterMap.dart @@ -1,27 +1,65 @@ +import "package:apple_maps_flutter/apple_maps_flutter.dart" as apple_maps; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; import 'package:locus/constants/values.dart'; +import 'package:locus/services/current_location_service.dart'; import 'package:locus/utils/location/get-fallback-location.dart'; import 'package:locus/utils/theme.dart'; - -import "package:latlong2/latlong.dart"; +import 'package:locus/widgets/LocationsMap.dart'; +import 'package:provider/provider.dart'; class LocusFlutterMap extends StatelessWidget { - final List children; + final List flutterChildren; final List nonRotatedChildren; - final MapOptions? options; - final MapController? mapController; + final MapOptions? flutterMapOptions; + final double? initialZoom; + final MapController? flutterMapController; + final apple_maps.AppleMapController? appleMapController; + final Set appleMapCircles; + final void Function(apple_maps.AppleMapController)? onAppleMapCreated; + + final void Function(LatLng)? onTap; + final void Function(LatLng)? onLongPress; const LocusFlutterMap({ super.key, - this.options, - this.children = const [], + this.flutterMapOptions, + this.flutterChildren = const [], this.nonRotatedChildren = const [], - this.mapController, + this.appleMapCircles = const {}, + this.initialZoom, + this.flutterMapController, + this.appleMapController, + this.onTap, + this.onLongPress, + this.onAppleMapCreated, }); - @override - Widget build(BuildContext context) { + LatLng getInitialPosition(final BuildContext context) { + final currentLocation = context.read(); + + return currentLocation.currentPosition == null + ? getFallbackLocation(context) + : LatLng( + currentLocation.currentPosition!.latitude, + currentLocation.currentPosition!.longitude, + ); + } + + double getInitialZoom(final BuildContext context) { + if (initialZoom != null) { + return initialZoom!; + } + + final currentLocation = context.read(); + + return currentLocation.currentPosition == null + ? FALLBACK_LOCATION_ZOOM_LEVEL + : 13.0; + } + + Widget buildFlutterMaps(final BuildContext context) { final isDarkMode = getIsDarkMode(context); final tileLayer = TileLayer( @@ -31,15 +69,17 @@ class LocusFlutterMap extends StatelessWidget { ); return FlutterMap( - options: options ?? + options: flutterMapOptions ?? MapOptions( maxZoom: 18, minZoom: 2, - center: getFallbackLocation(context), - zoom: FALLBACK_LOCATION_ZOOM_LEVEL, + center: getInitialPosition(context), + zoom: getInitialZoom(context), + onTap: (_, location) => onTap?.call(location), + onLongPress: (_, location) => onTap?.call(location), ), nonRotatedChildren: nonRotatedChildren, - mapController: mapController, + mapController: flutterMapController, children: [ if (isDarkMode) ColorFiltered( @@ -57,8 +97,37 @@ class LocusFlutterMap extends StatelessWidget { ) else tileLayer, - ...children, + ...flutterChildren, ], ); } + + Widget buildAppleMaps(final BuildContext context) { + return apple_maps.AppleMap( + initialCameraPosition: apple_maps.CameraPosition( + target: toAppleMapsCoordinates(getInitialPosition(context)), + zoom: getInitialZoom(context), + ), + compassEnabled: true, + onTap: (location) => onTap?.call(LatLng( + location.latitude, + location.longitude, + )), + onLongPress: (location) => onLongPress?.call(LatLng( + location.latitude, + location.longitude, + )), + onMapCreated: onAppleMapCreated, + circles: appleMapCircles, + ); + } + + @override + Widget build(BuildContext context) { + if (flutterMapController != null) { + return buildFlutterMaps(context); + } else { + return buildAppleMaps(context); + } + } } diff --git a/lib/widgets/MapActionsContainer.dart b/lib/widgets/MapActionsContainer.dart new file mode 100644 index 00000000..fba19fd2 --- /dev/null +++ b/lib/widgets/MapActionsContainer.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/constants/spacing.dart'; +import 'package:locus/screens/locations_overview_screen_widgets/constants.dart'; + +const MAP_ACTION_SIZE = 50.0; +const diff = FAB_SIZE - MAP_ACTION_SIZE; + +class MapActionsContainer extends StatelessWidget { + final List children; + + const MapActionsContainer({ + super.key, + required this.children, + }); + + @override + Widget build(BuildContext context) { + return Positioned( + // Add half the difference to center the button + right: FAB_MARGIN + diff / 2, + bottom: FAB_SIZE + + FAB_MARGIN + + (isCupertino(context) ? LARGE_SPACE : SMALL_SPACE), + child: Column( + children: children, + ), + ); + } +} diff --git a/lib/widgets/ModalSheet.dart b/lib/widgets/ModalSheet.dart index fbda1f8a..cfcff7af 100644 --- a/lib/widgets/ModalSheet.dart +++ b/lib/widgets/ModalSheet.dart @@ -4,7 +4,7 @@ import 'package:locus/utils/theme.dart'; import 'package:provider/provider.dart'; import '../constants/spacing.dart'; -import '../services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; class ModalSheet extends StatefulWidget { final Widget child; diff --git a/lib/widgets/ModalSheetContent.dart b/lib/widgets/ModalSheetContent.dart index 71616935..9bb8be99 100644 --- a/lib/widgets/ModalSheetContent.dart +++ b/lib/widgets/ModalSheetContent.dart @@ -4,7 +4,7 @@ import 'package:locus/utils/theme.dart'; import 'package:provider/provider.dart'; import '../constants/spacing.dart'; -import '../services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; class ModalSheetContent extends StatelessWidget { final String title; diff --git a/lib/widgets/OpenInMaps.dart b/lib/widgets/OpenInMaps.dart index 9d2f68f2..bfbbd4bc 100644 --- a/lib/widgets/OpenInMaps.dart +++ b/lib/widgets/OpenInMaps.dart @@ -6,7 +6,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' hide PlatformListTile; import 'package:flutter_svg/svg.dart'; import 'package:locus/constants/spacing.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:locus/widgets/ModalSheet.dart'; import 'package:locus/widgets/ModalSheetContent.dart'; import 'package:map_launcher/map_launcher.dart'; @@ -31,6 +31,23 @@ class OpenInMaps extends StatefulWidget { class _OpenInMapsState extends State { Future> mapFuture = MapLauncher.installedMaps; + @override + void initState() { + super.initState(); + + mapFuture.then((maps) { + if (maps.length == 1) { + // No selection to choose from, open directly + final map = maps[0]; + map.showDirections( + destination: widget.destination, + ); + + Navigator.pop(context); + } + }); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -83,20 +100,22 @@ class _OpenInMapsState extends State { const SizedBox(height: SMALL_SPACE), PlatformListTile( title: Text( - "Lat: ${widget.destination.latitude}, Long: ${widget.destination.longitude}", + "Lat: ${widget.destination.latitude}, Long: ${widget + .destination.longitude}", ), leading: PlatformIconButton( icon: PlatformWidget( material: (_, __) => const Icon(Icons.copy), cupertino: (_, __) => - const Icon(CupertinoIcons.doc_on_clipboard), + const Icon(CupertinoIcons.doc_on_clipboard), ), onPressed: () { // Copy to clipboard Clipboard.setData( ClipboardData( text: - "${widget.destination.latitude}, ${widget.destination.longitude}", + "${widget.destination.latitude}, ${widget + .destination.longitude}", ), ); }, diff --git a/lib/widgets/PlatformFlavorWidget.dart b/lib/widgets/PlatformFlavorWidget.dart index 1e46bc98..ba9fce54 100644 --- a/lib/widgets/PlatformFlavorWidget.dart +++ b/lib/widgets/PlatformFlavorWidget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:provider/provider.dart'; enum MIUIBehavior { diff --git a/lib/widgets/RelaySelectSheet.dart b/lib/widgets/RelaySelectSheet.dart index 6ca5108f..55488fd5 100644 --- a/lib/widgets/RelaySelectSheet.dart +++ b/lib/widgets/RelaySelectSheet.dart @@ -5,6 +5,7 @@ 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/api/get-relays-meta.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/constants/values.dart'; import 'package:locus/utils/load_status.dart'; @@ -69,8 +70,10 @@ class RelaySelectSheet extends StatefulWidget { } class _RelaySelectSheetState extends State { - List availableRelays = []; + final List availableRelays = []; + final Map relayMeta = {}; LoadStatus loadStatus = LoadStatus.loading; + final _searchController = TextEditingController(); late final DraggableScrollableController _sheetController; String _newValue = ''; @@ -83,7 +86,8 @@ class _RelaySelectSheetState extends State { @override void initState() { super.initState(); - fetchAvailableRelays(); + _fetchAvailableRelays(); + _fetchRelaysMeta(); widget.controller.addListener(rebuild); _searchController.addListener(() { @@ -160,7 +164,56 @@ class _RelaySelectSheetState extends State { setState(() {}); } - Future fetchAvailableRelays() async { + // Filters all relays whether they are suitable + void _filterRelaysFromMeta() { + if (relayMeta.isEmpty || availableRelays.isEmpty) { + return; + } + + final suitableRelays = relayMeta.values + .where((meta) => meta.isSuitable) + .map((meta) => meta.relay) + .toSet(); + + setState(() { + availableRelays.retainWhere(suitableRelays.contains); + availableRelays.sort( + (a, b) => relayMeta[a]!.score > relayMeta[b]!.score ? 1 : -1, + ); + loadStatus = LoadStatus.success; + }); + } + + Future _fetchRelaysMeta() async { + FlutterLogs.logInfo( + LOG_TAG, + "Relay Select Sheet", + "Fetching relays meta...", + ); + + try { + final relaysMetaDataRaw = + await withCache(fetchRelaysMeta, "relays-meta")(); + final relaysMetaData = relaysMetaDataRaw["meta"] as List; + final newRelays = Map.fromEntries( + relaysMetaData.map((meta) => MapEntry(meta.relay, meta)), + ); + + relayMeta.clear(); + relayMeta.addAll(newRelays); + _filterRelaysFromMeta(); + + setState(() {}); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Relay Select Sheet", + "Failed to fetch available relays: $error", + ); + } + } + + Future _fetchAvailableRelays() async { FlutterLogs.logInfo( LOG_TAG, "Relay Select Sheet", @@ -173,10 +226,12 @@ class _RelaySelectSheetState extends State { relays.shuffle(); - setState(() { - availableRelays = relays; - loadStatus = LoadStatus.success; - }); + availableRelays + ..clear() + ..addAll(relays); + _filterRelaysFromMeta(); + + setState(() {}); } catch (error) { FlutterLogs.logError( LOG_TAG, @@ -209,46 +264,86 @@ class _RelaySelectSheetState extends State { final allRelays = List.from( [...widget.controller.relays, ...uncheckedFoundRelays]); - final length = allRelays.length + (isValueNew ? 1 : 0); - return ListView.builder( controller: draggableController, - itemCount: length, + // Add 2 so we can show and widgets + itemCount: allRelays.length + 2, itemBuilder: (context, rawIndex) { - if (isValueNew && rawIndex == 0) { - return PlatformWidget( - material: (context, _) => ListTile( - title: Text( - l10n.addNewValueLabel(_newValue), - ), - leading: const Icon( - Icons.add, + if (rawIndex == 0) { + if (isValueNew) { + return PlatformWidget( + material: (context, _) => ListTile( + title: Text( + l10n.addNewValueLabel(_newValue), + ), + leading: const Icon( + Icons.add, + ), + onTap: () { + widget.controller.add(_searchController.value.text); + _searchController.clear(); + }, ), - onTap: () { - widget.controller.add(_searchController.value.text); - _searchController.clear(); - }, - ), - cupertino: (context, _) => CupertinoButton( - child: Text( - l10n.addNewValueLabel(_newValue), + cupertino: (context, _) => CupertinoButton( + child: Text( + l10n.addNewValueLabel(_newValue), + ), + onPressed: () { + widget.controller.add(_searchController.value.text); + _searchController.clear(); + }, ), - onPressed: () { - widget.controller.add(_searchController.value.text); - _searchController.clear(); - }, - ), - ); + ); + } + return Container(); + } + + if (rawIndex == 1) { + return loadStatus == LoadStatus.loading + ? Padding( + padding: const EdgeInsets.all(MEDIUM_SPACE), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: 20, + child: PlatformCircularProgressIndicator(), + ), + const SizedBox(width: MEDIUM_SPACE), + Text(l10n.relaySelectSheet_loadingRelaysMeta), + ], + ), + ) + : Padding( + padding: const EdgeInsets.all(MEDIUM_SPACE), + child: Row( + children: [ + Icon( + context.platformIcons.info, + color: getCaptionTextStyle(context).color, + ), + const SizedBox(width: MEDIUM_SPACE), + Flexible( + child: Text( + l10n.relaySelectSheet_hint, + style: getCaptionTextStyle(context), + ), + ), + ], + ), + ); } - final index = isValueNew ? rawIndex - 1 : rawIndex; + final index = rawIndex - 2; final relay = allRelays[index]; + final meta = relayMeta[relay]; return PlatformWidget( material: (context, _) => CheckboxListTile( title: Text( relay.length >= 6 ? relay.substring(6) : relay, ), + subtitle: meta == null ? null : Text(meta.description), value: widget.controller.relays.contains(relay), onChanged: (newValue) { if (newValue == null) { @@ -266,6 +361,7 @@ class _RelaySelectSheetState extends State { title: Text( relay.length >= 6 ? relay.substring(6) : relay, ), + subtitle: meta == null ? null : Text(meta.description), trailing: CupertinoSwitch( value: widget.controller.relays.contains(relay), onChanged: (newValue) { @@ -295,7 +391,7 @@ class _RelaySelectSheetState extends State { miuiIsGapless: true, child: Column( children: [ - if (loadStatus == LoadStatus.loading) + if (loadStatus == LoadStatus.loading && availableRelays.isEmpty) Expanded( child: Center( child: PlatformCircularProgressIndicator(), diff --git a/lib/widgets/RequestBatteryOptimizationsDisabledMixin.dart b/lib/widgets/RequestBatteryOptimizationsDisabledMixin.dart index 7de1bd07..81e436c4 100644 --- a/lib/widgets/RequestBatteryOptimizationsDisabledMixin.dart +++ b/lib/widgets/RequestBatteryOptimizationsDisabledMixin.dart @@ -1,11 +1,13 @@ +import 'dart:io'; + import 'package:disable_battery_optimization/disable_battery_optimization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/services/settings_service/index.dart'; +import 'package:locus/utils/repeatedly-check.dart'; import 'package:locus/utils/theme.dart'; import 'package:provider/provider.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -import '../services/settings_service.dart'; mixin RequestBatteryOptimizationsDisabledMixin { BuildContext get context; @@ -13,6 +15,10 @@ mixin RequestBatteryOptimizationsDisabledMixin { bool get mounted; Future showDisableBatteryOptimizationsDialog() async { + if (!Platform.isAndroid) { + return true; + } + final settings = context.read(); final status = @@ -60,19 +66,26 @@ mixin RequestBatteryOptimizationsDisabledMixin { l10n.permissions_autoStart_message, ); - final isIgnoringBatteryOptimizations = - (await DisableBatteryOptimization - .isBatteryOptimizationDisabled) ?? - false; - final isAutoStartEnabled = - (await DisableBatteryOptimization.isAutoStartEnabled) ?? - false; + final isIgnoring = await repeatedlyCheckForSuccess(() async { + final isIgnoringBatteryOptimizations = + (await DisableBatteryOptimization + .isBatteryOptimizationDisabled); + final isAutoStartEnabled = + (await DisableBatteryOptimization.isAutoStartEnabled); + + if (isIgnoringBatteryOptimizations == null || + isAutoStartEnabled == null) { + return null; + } + + return isIgnoringBatteryOptimizations && isAutoStartEnabled; + }); if (!context.mounted) { return; } - if (isIgnoringBatteryOptimizations && isAutoStartEnabled) { + if (isIgnoring == true) { Navigator.of(context).pop(true); } else { Navigator.of(context).pop(false); diff --git a/lib/widgets/RequestLocationPermissionMixin.dart b/lib/widgets/RequestLocationPermissionMixin.dart index 7f2556c1..53a305aa 100644 --- a/lib/widgets/RequestLocationPermissionMixin.dart +++ b/lib/widgets/RequestLocationPermissionMixin.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:geolocator/geolocator.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:locus/services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import 'package:provider/provider.dart'; import '../utils/theme.dart'; @@ -37,7 +37,9 @@ mixin RequestLocationPermissionMixin { builder: (context) => PlatformAlertDialog( title: Text(l10n.permissions_location_askPermission_title), material: (_, __) => MaterialAlertDialogData( - icon: settings.isMIUI() ? const Icon(CupertinoIcons.location_fill) : const Icon(Icons.location_on_rounded), + icon: settings.isMIUI() + ? const Icon(CupertinoIcons.location_fill) + : const Icon(Icons.location_on_rounded), ), content: Text( askForAlways @@ -49,9 +51,12 @@ mixin RequestLocationPermissionMixin { [ PlatformDialogAction( material: (_, __) => MaterialDialogActionData( - icon: settings.isMIUI() ? null : const Icon(Icons.check_circle_outline_rounded), + icon: settings.isMIUI() + ? null + : const Icon(Icons.check_circle_outline_rounded), ), - child: Text(l10n.permissions_location_askPermission_action_grant_label), + child: Text( + l10n.permissions_location_askPermission_action_grant_label), onPressed: () async { final newPermission = await Geolocator.requestPermission(); @@ -60,7 +65,8 @@ mixin RequestLocationPermissionMixin { } if ((newPermission == LocationPermission.always) || - (newPermission == LocationPermission.whileInUse && !askForAlways)) { + (newPermission == LocationPermission.whileInUse && + !askForAlways)) { Navigator.of(context).pop(true); return; } @@ -124,7 +130,8 @@ mixin RequestLocationPermissionMixin { ), child: Text(l10n.permissions_openSettings_label), onPressed: () async { - final openedSettingsSuccessfully = await Geolocator.openAppSettings(); + final openedSettingsSuccessfully = + await Geolocator.openAppSettings(); if (!context.mounted) { return; @@ -153,7 +160,8 @@ mixin RequestLocationPermissionMixin { context: context, builder: (context) => PlatformAlertDialog( title: Text(l10n.permissions_openSettings_failed_title), - content: Text(l10n.permissions_location_permissionDenied_settingsNotOpened_message), + content: Text(l10n + .permissions_location_permissionDenied_settingsNotOpened_message), actions: [ PlatformDialogAction( child: Text(l10n.closeNeutralAction), diff --git a/lib/widgets/RequestNotificationPermissionMixin.dart b/lib/widgets/RequestNotificationPermissionMixin.dart index c04f5d2c..5d98934a 100644 --- a/lib/widgets/RequestNotificationPermissionMixin.dart +++ b/lib/widgets/RequestNotificationPermissionMixin.dart @@ -9,7 +9,7 @@ import 'package:locus/utils/permissions/has-granted.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; -import '../services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; import '../utils/theme.dart'; mixin RequestNotificationPermissionMixin { @@ -34,60 +34,64 @@ mixin RequestNotificationPermissionMixin { final hasGranted = await showPlatformDialog( context: context, barrierDismissible: true, - builder: (context) => PlatformAlertDialog( - title: Text(l10n.permissions_notification_askPermission_title), - material: (_, __) => MaterialAlertDialogData( - icon: settings.isMIUI() - ? const Icon(CupertinoIcons.bell_fill) - : const Icon(Icons.notifications_rounded), - ), - content: Text(l10n.permissions_notification_askPermission_message), - actions: createCancellableDialogActions( - context, - [ - PlatformDialogAction( - material: (_, __) => MaterialDialogActionData( - icon: settings.isMIUI() - ? null - : const Icon(Icons.check_circle_outline_rounded), - ), - child: Text( - l10n.permissions_location_askPermission_action_grant_label), - onPressed: () async { - late final bool? success; - - if (Platform.isAndroid) { - success = await notificationsPlugins - .resolvePlatformSpecificImplementation< + builder: (context) => + PlatformAlertDialog( + title: Text(l10n.permissions_notification_askPermission_title), + material: (_, __) => + MaterialAlertDialogData( + icon: settings.isMIUI() + ? const Icon(CupertinoIcons.bell_fill) + : const Icon(Icons.notifications_rounded), + ), + content: Text(l10n.permissions_notification_askPermission_message), + actions: createCancellableDialogActions( + context, + [ + PlatformDialogAction( + material: (_, __) => + MaterialDialogActionData( + icon: settings.isMIUI() + ? null + : const Icon(Icons.check_circle_outline_rounded), + ), + child: Text( + l10n + .permissions_location_askPermission_action_grant_label), + onPressed: () async { + late final bool? success; + + if (Platform.isAndroid) { + success = await notificationsPlugins + .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>() - ?.requestPermission(); - } else { - success = await notificationsPlugins - .resolvePlatformSpecificImplementation< + ?.requestPermission(); + } else { + success = await notificationsPlugins + .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>() - ?.requestPermissions( + ?.requestPermissions( alert: true, badge: true, sound: true, ); - } - - if (!context.mounted) { - return; - } - - if (success == true) { - Navigator.of(context).pop(true); - return; - } else { - Navigator.of(context).pop(false); - return; - } - }, - ) - ], - ), - ), + } + + if (!context.mounted) { + return; + } + + if (success == true) { + Navigator.of(context).pop(true); + return; + } else { + Navigator.of(context).pop(false); + return; + } + }, + ) + ], + ), + ), ); if (hasGranted == true) { @@ -107,35 +111,40 @@ mixin RequestNotificationPermissionMixin { final settingsResult = await showPlatformDialog( context: context, barrierDismissible: true, - builder: (context) => PlatformAlertDialog( - material: (_, __) => MaterialAlertDialogData( - icon: settings.getAndroidTheme() == AndroidTheme.miui - ? const Icon(CupertinoIcons.exclamationmark_triangle_fill) - : const Icon(Icons.warning_rounded), - ), - title: Text(l10n.permissions_openSettings_failed_title), - content: Text(l10n.permissions_notification_permissionDenied_message), - actions: createCancellableDialogActions( - context, - [ - PlatformDialogAction( - material: (_, __) => MaterialDialogActionData( - icon: settings.isMIUI() ? null : const Icon(Icons.settings), - ), - child: Text(l10n.permissions_openSettings_label), - onPressed: () async { - final openedSettingsSuccessfully = await openAppSettings(); - - if (!context.mounted) { - return; - } - - Navigator.of(context).pop(openedSettingsSuccessfully); - }, - ) - ], - ), - ), + builder: (context) => + PlatformAlertDialog( + material: (_, __) => + MaterialAlertDialogData( + icon: settings.getAndroidTheme() == AndroidTheme.miui + ? const Icon(CupertinoIcons.exclamationmark_triangle_fill) + : const Icon(Icons.warning_rounded), + ), + title: Text(l10n.permissions_openSettings_failed_title), + content: Text( + l10n.permissions_notification_permissionDenied_message), + actions: createCancellableDialogActions( + context, + [ + PlatformDialogAction( + material: (_, __) => + MaterialDialogActionData( + icon: settings.isMIUI() ? null : const Icon( + Icons.settings), + ), + child: Text(l10n.permissions_openSettings_label), + onPressed: () async { + final openedSettingsSuccessfully = await openAppSettings(); + + if (!context.mounted) { + return; + } + + Navigator.of(context).pop(openedSettingsSuccessfully); + }, + ) + ], + ), + ), ); if (!mounted) { @@ -151,17 +160,18 @@ mixin RequestNotificationPermissionMixin { if (settingsResult == false) { await showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - title: Text(l10n.permissions_openSettings_failed_title), - content: Text(l10n - .permissions_notification_permissionDenied_settingsNotOpened_message), - actions: [ - PlatformDialogAction( - child: Text(l10n.closeNeutralAction), - onPressed: () => Navigator.of(context).pop(), - ) - ], - ), + builder: (context) => + PlatformAlertDialog( + title: Text(l10n.permissions_openSettings_failed_title), + content: Text(l10n + .permissions_notification_permissionDenied_settingsNotOpened_message), + actions: [ + PlatformDialogAction( + child: Text(l10n.closeNeutralAction), + onPressed: () => Navigator.of(context).pop(), + ) + ], + ), ); } diff --git a/lib/widgets/SettingsCaretIcon.dart b/lib/widgets/SettingsCaretIcon.dart index ea1dfe3a..ecb024f6 100644 --- a/lib/widgets/SettingsCaretIcon.dart +++ b/lib/widgets/SettingsCaretIcon.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; -import '../services/settings_service.dart'; +import 'package:locus/services/settings_service/index.dart'; /// A widget that displays a caret icon, if required. /// For example, on MIUI and iOS, a caret icon is displayed. diff --git a/lib/widgets/TimerWidgetSheet.dart b/lib/widgets/TimerWidgetSheet.dart index 3a046e5b..2accbe4d 100644 --- a/lib/widgets/TimerWidgetSheet.dart +++ b/lib/widgets/TimerWidgetSheet.dart @@ -5,7 +5,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/constants/timers.dart'; -import 'package:locus/services/task_service.dart'; +import 'package:locus/services/task_service/index.dart'; import 'package:locus/services/timers_service.dart'; import 'package:locus/widgets/ModalSheet.dart'; import 'package:locus/widgets/ModalSheetContent.dart'; @@ -56,146 +56,138 @@ class _TimerWidgetSheetState extends State { minChildSize: 0.6, maxChildSize: 0.6, expand: false, - builder: (_, __) => - ModalSheet( - child: ModalSheetContent( - title: l10n.detailsTimersLabel, - submitLabel: l10n.closePositiveSheetAction, - onSubmit: (widget.controller.timers.isEmpty && !widget.allowEmpty) - ? null - : () { - Navigator.of(context).pop(widget.controller.timers); - }, - children: [ - if (widget.controller.timers.isNotEmpty) ...[ - Expanded( - child: TimerWidget( - controller: widget.controller, - ), - ), - const SizedBox(height: SMALL_SPACE), - if (findNextStartDate(widget.controller.timers) == null) - Text(l10n.timer_executionStartsImmediately) - else - Text(l10n.timer_nextExecution( - findNextStartDate(widget.controller.timers)!)), - if (widget.controller.timers - .any((timer) => timer.isInfinite())) ...[ - const SizedBox(height: SMALL_SPACE), - WarningText(l10n.timer_runsInfiniteMessage), - ], - ], - const SizedBox(height: MEDIUM_SPACE), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - PlatformPopup( - type: PlatformPopupType.longPress, - items: List>.from( - WEEKDAY_TIMERS.entries.map( - (entry) => - PlatformPopupMenuItem( - label: Text(entry.value["name"] as String), - onPressed: () { - widget.controller.clear(); - - final timers = - entry.value["timers"] as List; - widget.controller.addAll(timers); - - Navigator.pop(context); - }, - ), - ), - ), - child: PlatformTextButton( - child: Text(l10n.timer_addWeekday), - material: (_, __) => - MaterialTextButtonData( - icon: const Icon(Icons.date_range_rounded), - ), - onPressed: () async { - final data = await showPlatformDialog( - context: context, - builder: (_) => const WeekdaySelection(), - ); - - if (!mounted) { - return; - } - - if (data != null) { - widget.controller.add( - WeekdayTimer( - day: data["weekday"] as int, - startTime: data["startTime"] as TimeOfDay, - endTime: data["endTime"] as TimeOfDay, - ), - ); - } + builder: (_, __) => ModalSheet( + child: ModalSheetContent( + title: l10n.detailsTimersLabel, + submitLabel: l10n.closePositiveSheetAction, + onSubmit: (widget.controller.timers.isEmpty && !widget.allowEmpty) + ? null + : () { + Navigator.of(context).pop(widget.controller.timers); + }, + children: [ + if (widget.controller.timers.isNotEmpty) ...[ + Expanded( + child: TimerWidget( + controller: widget.controller, + ), + ), + const SizedBox(height: SMALL_SPACE), + if (findNextStartDate(widget.controller.timers) == null) + Text(l10n.timer_executionStartsImmediately) + else + Text(l10n.timer_nextExecution( + findNextStartDate(widget.controller.timers)!)), + if (widget.controller.timers + .any((timer) => timer.isInfinite())) ...[ + const SizedBox(height: SMALL_SPACE), + WarningText(l10n.timer_runsInfiniteMessage), + ], + ], + const SizedBox(height: MEDIUM_SPACE), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + PlatformPopup( + type: PlatformPopupType.longPress, + items: List>.from( + WEEKDAY_TIMERS.entries.map( + (entry) => PlatformPopupMenuItem( + label: Text(entry.value["name"] as String), + onPressed: () { + widget.controller.clear(); + + final timers = + entry.value["timers"] as List; + widget.controller.addAll(timers); + + Navigator.pop(context); }, ), ), - PlatformTextButton( - child: Text(l10n.timer_addDuration), - material: (_, __) => - MaterialTextButtonData( - icon: const Icon(Icons.timelapse_rounded), + ), + child: PlatformTextButton( + child: Text(l10n.timer_addWeekday), + material: (_, __) => MaterialTextButtonData( + icon: const Icon(Icons.date_range_rounded), + ), + onPressed: () async { + final data = await showPlatformDialog( + context: context, + builder: (_) => const WeekdaySelection(), + ); + + if (!mounted) { + return; + } + + if (data != null) { + widget.controller.add( + WeekdayTimer( + day: data["weekday"] as int, + startTime: data["startTime"] as TimeOfDay, + endTime: data["endTime"] as TimeOfDay, ), - onPressed: () async { - Duration? duration; - - if (isCupertino(context)) { - await showCupertinoModalPopup( - context: context, - builder: (context) => - Container( - height: 300, - padding: const EdgeInsets.only(top: 6.0), - margin: EdgeInsets.only( - bottom: MediaQuery - .of(context) - .viewInsets - .bottom, - ), - color: CupertinoColors.systemBackground - .resolveFrom(context), - child: SafeArea( - top: false, - child: CupertinoTimerPicker( - initialTimerDuration: Duration.zero, - minuteInterval: 5, - onTimerDurationChanged: (value) { - duration = value; - }, - mode: CupertinoTimerPickerMode.hm, - ), - ), - ), - ); - } else { - duration = await showDurationPicker( - context: context, - initialTime: Duration.zero, - snapToMins: 15.0, - ); - } - - if (duration != null && duration!.inSeconds > 0) { - widget.controller.add( - DurationTimer( - duration: duration!, + ); + } + }, + ), + ), + PlatformTextButton( + child: Text(l10n.timer_addDuration), + material: (_, __) => MaterialTextButtonData( + icon: const Icon(Icons.timelapse_rounded), + ), + onPressed: () async { + Duration? duration; + + if (isCupertino(context)) { + await showCupertinoModalPopup( + context: context, + builder: (context) => Container( + height: 300, + padding: const EdgeInsets.only(top: 6.0), + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + color: CupertinoColors.systemBackground + .resolveFrom(context), + child: SafeArea( + top: false, + child: CupertinoTimerPicker( + initialTimerDuration: Duration.zero, + minuteInterval: 5, + onTimerDurationChanged: (value) { + duration = value; + }, + mode: CupertinoTimerPickerMode.hm, ), - ); - } - }, - ), - ], + ), + ), + ); + } else { + duration = await showDurationPicker( + context: context, + initialTime: Duration.zero, + snapToMins: 15.0, + ); + } + + if (duration != null && duration!.inSeconds > 0) { + widget.controller.add( + DurationTimer( + duration: duration!, + ), + ); + } + }, ), ], ), - ), + ], + ), + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index 1109645b..8b9a2b1d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,62 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" - audioplayers: - dependency: "direct main" - description: - name: audioplayers - sha256: "61583554386721772f9309f509e17712865b38565a903c761f96b1115a979282" - url: "https://pub.dev" - source: hosted - version: "4.1.0" - audioplayers_android: - dependency: transitive - description: - name: audioplayers_android - sha256: dbdc9b7f2aa2440314c638aa55aadd45c7705e8340d5eddf2e3fb8da32d4ae2c - url: "https://pub.dev" - source: hosted - version: "3.0.2" - audioplayers_darwin: - dependency: transitive - description: - name: audioplayers_darwin - sha256: "6aea96df1d12f7ad5a71d88c6d1b22a216211a9564219920124c16768e456e9d" - url: "https://pub.dev" - source: hosted - version: "4.1.0" - audioplayers_linux: - dependency: transitive - description: - name: audioplayers_linux - sha256: "396b62ac62c92dd26c3bc5106583747f57a8b325ebd2b41e5576f840cfc61338" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - audioplayers_platform_interface: - dependency: transitive - description: - name: audioplayers_platform_interface - sha256: f7daaed4659143094151ecf6bacd927d29ab8acffba98c110c59f0b81ae51143 - url: "https://pub.dev" - source: hosted - version: "5.0.1" - audioplayers_web: - dependency: transitive - description: - name: audioplayers_web - sha256: ec84fd46eed1577148ed4113f5998a36a18da4fce7170c37ce3e21b631393339 - url: "https://pub.dev" - source: hosted - version: "3.1.0" - audioplayers_windows: - dependency: transitive - description: - name: audioplayers_windows - sha256: "1d3aaac98a192b8488167711ba1e67d8b96333e8d0572ede4e2912e5bbce69a3" - url: "https://pub.dev" - source: hosted - version: "2.0.2" background_fetch: dependency: "direct main" description: @@ -137,14 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.6" + background_locator_2: + dependency: "direct main" + description: + name: background_locator_2 + sha256: "145ef41767306052cbb2159478c0369b83c9800740f7acc656530c263d371695" + url: "https://pub.dev" + source: hosted + version: "2.0.6" basic_utils: dependency: "direct main" description: name: basic_utils - sha256: "8815477fcf58499e42326bd858e391442425fa57db9a45e48e15224c62049262" + sha256: "5748b8a2e810bba86da623940ac5c39874760a8f7cf02e62b1787a26f42f33bf" url: "https://pub.dev" source: hosted - version: "5.5.4" + version: "5.6.0" battery_plus: dependency: "direct main" description: @@ -498,10 +450,10 @@ packages: dependency: transitive description: name: flutter_compass - sha256: "1a0121bff32df95193812b4e0f69e95f45fdec042ebd7a326ba087c0f6ec8304" + sha256: be642484f9f6975c1c6edff568281b001f2f1e604de27ecea18d97eebbdef22f url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.8.0" flutter_expandable_fab: dependency: "direct main" description: @@ -559,34 +511,34 @@ packages: dependency: "direct main" description: name: flutter_logs - sha256: dd037783f0f22fe03f359b65f913af275aeac4a1474c18c41b2ed9fdbd4f83f1 + sha256: "0fb84868b02e5880a1488f5282a4e193a1293280b85be7a56e58f29c0d1e6bea" url: "https://pub.dev" source: hosted - version: "2.1.10" + version: "2.1.11" flutter_map: dependency: "direct main" description: name: flutter_map - sha256: "52c65a977daae42f9aae6748418dd1535eaf27186e9bac9bf431843082bc75a3" + sha256: "5286f72f87deb132daa1489442d6cc46e986fc105cb727d9ae1b602b35b1d1f3" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" flutter_map_location_marker: dependency: "direct main" description: name: flutter_map_location_marker - sha256: "9757dceadda71a53d2d4004cff4d53a29210086083bdfebf44a1c4feb07f8eb1" + sha256: "84464cf16ddaf089dd35a2b2a5c4be3571799d6307f4a54a7d645ab958ff3132" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "7.0.5" flutter_map_marker_popup: dependency: "direct main" description: name: flutter_map_marker_popup - sha256: "71457476f91d6174c132577d37c11a78ad9502be65e699b22aa7a745b07414a5" + sha256: be209c68b19d4c10d9a2f5911e45f7c579624c43a353adb9bf0f2fec0cf30b8c url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "5.2.0" flutter_osm_interface: dependency: transitive description: @@ -737,50 +689,50 @@ packages: dependency: "direct main" description: name: geolocator - sha256: "5c23f3613f50586c0bbb2b8f970240ae66b3bd992088cf60dd5ee2e6f7dde3a8" + sha256: e946395fc608842bb2f6c914807e9183f86f3cb787f6b8f832753e5251036f02 url: "https://pub.dev" source: hosted - version: "9.0.2" + version: "10.1.0" geolocator_android: dependency: transitive description: name: geolocator_android - sha256: b06c72853c993ae533f482d81a12805d7a441f5231d9668718bc7131d7464082 + sha256: fb7fc45ce08714a17d1bb097c58b751a10c12255c35c3f64a3c6922222d93be2 url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.3.0" geolocator_apple: dependency: transitive description: name: geolocator_apple - sha256: "22b60ca3b8c0f58e6a9688ff855ee39ab813ca3f0c0609a48d282f6631266f2e" + sha256: "2c0187c84ca04fdb4e2b32a775dbe97448622a53f1c9ecb26d8b6b0df6e72b65" url: "https://pub.dev" source: hosted - version: "2.2.5" + version: "2.3.1" geolocator_platform_interface: dependency: transitive description: name: geolocator_platform_interface - sha256: af4d69231452f9620718588f41acc4cb58312368716bfff2e92e770b46ce6386 + sha256: b8cc1d3be0ca039a3f2174b0b026feab8af3610e220b8532e42cff8ec6658535 url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.1.0" geolocator_web: dependency: transitive description: name: geolocator_web - sha256: f68a122da48fcfff68bbc9846bb0b74ef651afe84a1b1f6ec20939de4d6860e1 + sha256: "59083f7e0871b78299918d92bf930a14377f711d2d1156c558cd5ebae6c20d58" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.2.0" geolocator_windows: dependency: transitive description: name: geolocator_windows - sha256: f5911c88e23f48b598dd506c7c19eff0e001645bdc03bb6fecb9f4549208354d + sha256: "8725beaa00db2b52f53d9811584cb4488240b250b04a09763e80945017f65c9c" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.2.1" get: dependency: transitive description: @@ -825,10 +777,10 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.1.0" http_multi_server: dependency: transitive description: @@ -889,10 +841,10 @@ packages: dependency: "direct main" description: name: latlong2 - sha256: "08ef7282ba9f76e8495e49e2dc4d653015ac929dce5f92b375a415d30b407ea0" + sha256: "18712164760cee655bc790122b0fd8f3d5b3c36da2cb7bf94b68a197fbb0811b" url: "https://pub.dev" source: hosted - version: "0.8.2" + version: "0.9.0" lints: dependency: transitive description: @@ -953,10 +905,10 @@ packages: dependency: "direct main" description: name: logger - sha256: db2ff852ed77090ba9f62d3611e4208a3d11dfa35991a81ae724c113fcb3e3f7 + sha256: ba3bc83117b2b49bdd723c0ea7848e8285a0fbc597ba09203b20d329d020c24a url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "2.0.2" logging: dependency: transitive description: @@ -1277,6 +1229,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + queue: + dependency: "direct main" + description: + name: queue + sha256: "9a41ecadc15db79010108c06eae229a45c56b18db699760f34e8c9ac9b831ff9" + url: "https://pub.dev" + source: hosted + version: "3.1.0+2" quick_actions: dependency: "direct main" description: @@ -1426,14 +1386,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" - url: "https://pub.dev" - source: hosted - version: "3.1.0" term_glyph: dependency: transitive description: @@ -1466,14 +1418,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" - tuple: - dependency: transitive - description: - name: tuple - sha256: "0ea99cd2f9352b2586583ab2ce6489d1f95a5f6de6fb9492faaf97ae2060f0aa" - url: "https://pub.dev" - source: hosted - version: "2.0.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9bf4d6fe..4a8b576b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,10 +17,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.14.3+35 +version: 0.15.0+36 environment: - sdk: '>=2.18.5 <3.0.0' + sdk: '>=3.0.0' # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -40,12 +40,12 @@ dependencies: cupertino_icons: ^1.0.5 flutter_secure_storage: ^8.0.0 intl: any - http: ^0.13.6 + http: ^1.0.0 nostr: ^1.3.4 uuid: ^3.0.7 provider: ^6.0.5 - logger: ^1.3.0 - geolocator: ^9.0.2 + logger: ^2.0.2 + geolocator: ^10.1.0 flutter_osm_plugin: ^0.53.4+1 clipboard: ^0.1.3 uni_links: ^0.5.1 @@ -84,23 +84,24 @@ dependencies: version: ^3.0.2 nearby_connections: ^3.3.0 english_words: ^4.0.0 - flutter_map: ^4.0.0 + flutter_map: ^5.0.0 flutter_map_marker_popup: ^5.1.0 material_design_icons_flutter: ^6.0.7096 easy_debounce: ^2.0.3 permission_handler: ^10.2.0 - audioplayers: ^4.1.0 - flutter_logs: ^2.1.10 + flutter_logs: ^2.1.11 vibration: ^1.7.7 local_auth: ^2.1.6 wakelock: ^0.4.0 figma_squircle: ^0.5.3 get_time_ago: ^1.2.2 flutter_expandable_fab: ^1.8.1 - flutter_map_location_marker: ^6.0.0 + flutter_map_location_marker: ^7.0.5 simple_shadow: ^0.3.1 - latlong2: ^0.8.2 + latlong2: ^0.9.0 collection: ^1.17.1 + background_locator_2: ^2.0.6 + queue: ^3.1.0+2 # Uncomment this for publishing FLOSS variant # Taken from https://github.com/Zverik/every_door/blob/aaf8d2fdeac483041bcac2c7c79ef760b99dff2b/pubspec.yaml#L55 @@ -159,7 +160,6 @@ flutter: - assets/logo.svg - assets/location-out-of-bounds-marker.svg - assets/honorable-mentions/ - - assets/bunny.mp3 # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware diff --git a/test/location_alarm_service_test.dart b/test/location_alarm_service_test.dart index 54199a07..3a650f41 100644 --- a/test/location_alarm_service_test.dart +++ b/test/location_alarm_service_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:locus/services/location_alarm_service.dart'; import 'package:latlong2/latlong.dart'; +import 'package:locus/services/location_alarm_service/enums.dart'; +import 'package:locus/services/location_alarm_service/index.dart'; import 'package:locus/services/location_point_service.dart'; // DuckDuckGo's headquarter @@ -13,8 +14,8 @@ final MAYBE_POINT = LatLng(40.04136, -75.48662); void main() { group("Radius based location with whenEnter", () { - final alarm = RadiusBasedRegionLocationAlarm.create( - type: RadiusBasedRegionLocationAlarmType.whenEnter, + final alarm = GeoLocationAlarm.create( + type: LocationRadiusBasedTriggerType.whenEnter, center: CENTER, radius: RADIUS, zoneName: "Test", @@ -62,8 +63,8 @@ void main() { }); group("Radius based location with whenLeave", () { - final alarm = RadiusBasedRegionLocationAlarm.create( - type: RadiusBasedRegionLocationAlarmType.whenLeave, + final alarm = GeoLocationAlarm.create( + type: LocationRadiusBasedTriggerType.whenLeave, center: CENTER, radius: RADIUS, zoneName: "Test", diff --git a/test/nostr_fetch_test.dart b/test/nostr_fetch_test.dart new file mode 100644 index 00000000..fee94e8a --- /dev/null +++ b/test/nostr_fetch_test.dart @@ -0,0 +1,111 @@ +import 'dart:io'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:locus/constants/values.dart'; +import 'package:locus/services/location_fetcher_service/Fetcher.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/task_service/index.dart'; +import 'package:locus/services/view_service/index.dart'; +import 'package:locus/utils/cryptography/utils.dart'; +import 'package:locus/utils/nostr_fetcher/LocationPointDecrypter.dart'; +import 'package:locus/utils/nostr_fetcher/NostrSocket.dart'; +import 'package:nostr/nostr.dart'; + +import 'utils.dart'; + +// Trustable relay for testing +const RELAY_URI = "wss://relay.damus.io"; +// DuckDuckGo's headquarter +final CENTER = LatLng(40.04114, -75.48702); +final SECOND_LOCATION = LocationPointService.dummyFromLatLng(LatLng(0, 0)); +final LOCATION_POINT = LocationPointService.dummyFromLatLng(CENTER); + +void main() { + group("Nostr Fetchers", () { + testWidgets("throws error on non-existent relay", (tester) async { + setupFlutterLogs(tester); + + await tester.runAsync(() async { + final randomSuffix = DateTime.now().millisecondsSinceEpoch.toString(); + final nonExistent = + "wss://donotbuythisdomainxasdyxcybvbnhzhj$randomSuffix.com"; + final secretKey = await generateSecretKey(); + + final fetcher = NostrSocket( + relay: nonExistent, + decryptMessage: LocationPointDecrypter( + secretKey, + ).decryptFromNostrMessage, + ); + + try { + await fetcher.connect(); + } on SocketException catch (error) { + return; + } + }); + }); + + testWidgets("can save and fetch location point", (tester) async { + setupFlutterLogs(tester); + + await tester.runAsync(() async { + // Publish + final task = await Task.create("Test", [RELAY_URI]); + await task.publisher.publishLocation(LOCATION_POINT); + + // Fetch + final fetcher = NostrSocket( + relay: RELAY_URI, + decryptMessage: task.cryptography.decryptFromNostrMessage, + ); + + await fetcher.connect(); + fetcher.addData( + Request( + generate64RandomHexChars(), + [ + NostrSocket.createNostrRequestDataFromTask(task, limit: 1), + ], + ).serialize(), + ); + await fetcher.onComplete; + + final locations = await fetcher.stream.toList(); + + expect(locations.length, 1); + expect(locations[0].latitude, LOCATION_POINT.latitude); + expect(locations[0].longitude, LOCATION_POINT.longitude); + }); + }); + + testWidgets("Fetcher works", (tester) async { + setupFlutterLogs(tester); + + await tester.runAsync(() async { + // Publish + final task = await Task.create("Test", [RELAY_URI]); + await task.publisher.publishLocation(LOCATION_POINT); + await task.publisher.publishLocation(SECOND_LOCATION); + + // Fetch + final view = task.createTaskView_onlyForTesting(); + final fetcher = Fetcher(view); + await fetcher.fetchAllLocations(); + final locations = fetcher.sortedLocations; + + expect(locations.length, 2); + expect(locations[0].latitude, LOCATION_POINT.latitude); + expect(locations[0].longitude, LOCATION_POINT.longitude); + expect(locations[1].latitude, SECOND_LOCATION.latitude); + expect(locations[1].longitude, SECOND_LOCATION.longitude); + }); + }); + }); +} diff --git a/test/utils.dart b/test/utils.dart new file mode 100644 index 00000000..a85e3972 --- /dev/null +++ b/test/utils.dart @@ -0,0 +1,17 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void setupFlutterLogs(final WidgetTester tester) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel("flutter_logs"), + (call) async { + if (call.method == "logThis") { + print( + "{${call.arguments['tag']}} {${call.arguments['subTag']}} {${call.arguments['logMessage']}} {${DateTime.now().toIso8601String()} {${call.arguments['level']}}", + ); + } + + return ""; + }, + ); +}