diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6491826d..1e9d191a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -167,6 +167,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", @@ -766,6 +767,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": { diff --git a/lib/main.dart b/lib/main.dart index 49c5c2aa..79c120b5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ 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/screens/locations_overview_screen_widgets/LocationFetchers.dart'; import 'package:locus/services/app_update_service.dart'; import 'package:locus/services/log_service.dart'; import 'package:locus/services/manager_service/background_fetch.dart'; @@ -84,6 +85,8 @@ void main() async { ChangeNotifierProvider(create: (_) => logService), ChangeNotifierProvider( create: (_) => appUpdateService), + ChangeNotifierProvider( + create: (_) => LocationFetchers()), ], child: const App(), ), diff --git a/lib/screens/LocationsOverviewScreen.dart b/lib/screens/LocationsOverviewScreen.dart index bee4eeed..06f9557c 100644 --- a/lib/screens/LocationsOverviewScreen.dart +++ b/lib/screens/LocationsOverviewScreen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:core'; import 'dart:io'; import 'dart:math'; @@ -23,10 +24,10 @@ 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/manager_service/background_locator.dart'; import 'package:locus/services/manager_service/helpers.dart'; import 'package:locus/services/settings_service/SettingsMapLocation.dart'; @@ -44,6 +45,7 @@ import 'package:locus/widgets/GoToMyLocationMapAction.dart'; import 'package:locus/widgets/LocationsMap.dart'; import 'package:locus/widgets/LocusFlutterMap.dart'; import 'package:locus/widgets/CompassMapAction.dart'; +import 'package:locus/widgets/MapActionsContainer.dart'; import 'package:locus/widgets/Paper.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; @@ -63,7 +65,7 @@ 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'; @@ -89,7 +91,6 @@ class _LocationsOverviewScreenState extends State AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin { - late final ViewLocationFetcher _fetchers; MapController? flutterMapController; PopupController? flutterMapPopupController; apple_maps.AppleMapController? appleMapController; @@ -105,6 +106,8 @@ class _LocationsOverviewScreenState extends State // Dummy stream to trigger updates to out of bound markers StreamController mapEventStream = StreamController.broadcast(); + LocationPointService? visibleLocation; + // Since we already listen to the latest position, we will pass it // manually to `current_location_layer` to avoid it also registering // extra listeners. @@ -142,35 +145,43 @@ class _LocationsOverviewScreenState extends State final logService = context.read(); final settings = context.read(); final appUpdateService = context.read(); + final locationFetchers = context.read(); - _createLocationFetcher(); _handleViewAlarmChecker(); _handleNotifications(); + locationFetchers.addAll(viewService.views); + settings.addListener(_updateBackgroundListeners); taskService.addListener(_updateBackgroundListeners); + locationFetchers.addLocationUpdatesListener(_rebuild); WidgetsBinding.instance ..addObserver(this) - ..addPostFrameCallback((_) async { + ..addPostFrameCallback((_) { _setLocationFromSettings(); _updateBackgroundListeners(); initQuickActions(context); _initUniLinks(); _updateLocaleToSettings(); _showUpdateDialogIfRequired(); + locationFetchers.fetchPreviewLocations(); taskService.checkup(logService); - _fetchers.addListener(_rebuild); appUpdateService.addListener(_rebuild); viewService.addListener(_handleViewServiceChange); - updateCurrentPosition( - askPermissions: false, - showErrorMessage: false, - goToPosition: true, - ); + Geolocator.checkPermission().then((status) { + if ({LocationPermission.always, LocationPermission.whileInUse} + .contains(status)) { + updateCurrentPosition( + askPermissions: false, + showErrorMessage: false, + goToPosition: true, + ); + } + }); }); if (settings.getMapProvider() == MapProvider.openStreetMap) { @@ -194,8 +205,10 @@ class _LocationsOverviewScreenState extends State @override dispose() { + final appUpdateService = context.read(); + final locationFetchers = context.read(); + flutterMapController?.dispose(); - _fetchers.dispose(); _viewsAlarmCheckerTimer?.cancel(); _uniLinksStream?.cancel(); @@ -205,8 +218,8 @@ class _LocationsOverviewScreenState extends State WidgetsBinding.instance.removeObserver(this); - final appUpdateService = context.read(); appUpdateService.removeListener(_rebuild); + locationFetchers.removeLocationUpdatesListener(_rebuild); super.dispose(); } @@ -226,9 +239,12 @@ class _LocationsOverviewScreenState extends State 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 { @@ -254,16 +270,14 @@ class _LocationsOverviewScreenState extends State } 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( @@ -271,17 +285,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; @@ -508,7 +514,7 @@ class _LocationsOverviewScreenState extends State Navigator.of(context).push( NativePageRoute( context: context, - builder: (_) => ViewDetailScreen( + builder: (_) => ViewDetailsScreen( view: viewService.getViewById(data["taskViewID"]), ), ), @@ -729,6 +735,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( @@ -758,34 +782,35 @@ class _LocationsOverviewScreenState extends State .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, @@ -796,7 +821,9 @@ class _LocationsOverviewScreenState extends State selectedViewID = view.id; }); }, - points: mergeLocationsIfRequired(entry.key) + // TODO + points: mergeLocationsIfRequired( + locationFetchers.getLocations(view)) .reversed .map( (location) => apple_maps.LatLng( @@ -813,6 +840,7 @@ class _LocationsOverviewScreenState extends State } final lastCenter = settings.getLastMapLocation()?.toLatLng(); + final colorOpacityMultiplier = selectedViewID == null ? 1.0 : .1; return LocusFlutterMap( mapController: flutterMapController, options: MapOptions( @@ -823,35 +851,50 @@ class _LocationsOverviewScreenState extends State ), children: [ 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), @@ -901,9 +944,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), @@ -933,18 +976,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, @@ -954,8 +999,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; @@ -965,12 +1014,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), @@ -1065,6 +1115,7 @@ class _LocationsOverviewScreenState extends State Navigator.pop(context); setState(() { selectedViewID = null; + visibleLocation = null; }); }, ) @@ -1123,6 +1174,7 @@ class _LocationsOverviewScreenState extends State setState(() { showFAB = true; selectedViewID = null; + visibleLocation = null; }); return; } @@ -1171,6 +1223,7 @@ class _LocationsOverviewScreenState extends State Navigator.pop(context); setState(() { selectedViewID = null; + visibleLocation = null; }); }, ) @@ -1204,19 +1257,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() { @@ -1276,69 +1328,62 @@ class _LocationsOverviewScreenState extends State 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; - }); - }, - ), + 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: 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), - 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, - ); - }, - ), - ], - ), + ), + 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, + ); + }, + ), + ], ); } @@ -1350,6 +1395,7 @@ class _LocationsOverviewScreenState extends State super.build(context); final settings = context.watch(); + final locationFetchers = context.watch(); final l10n = AppLocalizations.of(context); return PlatformScaffold( @@ -1434,11 +1480,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) { @@ -1452,6 +1503,11 @@ class _LocationsOverviewScreenState extends State ); } }, + onVisibleLocationChange: (location) { + setState(() { + visibleLocation = location; + }); + }, ), ActiveSharesSheet( visible: selectedViewID == null, @@ -1470,6 +1526,7 @@ class _LocationsOverviewScreenState extends State setState(() { showFAB = true; selectedViewID = null; + visibleLocation = null; }); createNewQuickLocationShare(); diff --git a/lib/screens/TaskDetailScreen.dart b/lib/screens/TaskDetailScreen.dart index a4b1a591..06d8a89c 100644 --- a/lib/screens/TaskDetailScreen.dart +++ b/lib/screens/TaskDetailScreen.dart @@ -39,52 +39,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 +102,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 +114,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 +121,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..700c3e66 --- /dev/null +++ b/lib/screens/ViewDetailsScreen.dart @@ -0,0 +1,147 @@ +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.dart'; +import 'package:locus/utils/PageRoute.dart'; +import 'package:locus/widgets/OpenInMaps.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 'package:provider/provider.dart'; + +import '../constants/spacing.dart'; +import '../utils/theme.dart'; +import '../widgets/PlatformListTile.dart'; +import 'locations_overview_screen_widgets/LocationFetchers.dart'; + +class ViewDetailsScreen extends StatefulWidget { + final TaskView view; + + const ViewDetailsScreen({ + super.key, + required this.view, + }); + + @override + State createState() => _ViewDetailsScreenState(); +} + +class _ViewDetailsScreenState extends State { + bool showAlarms = true; + + @override + Widget build(BuildContext context) { + final locationFetcher = context.watch(); + + final locations = locationFetcher.getLocations(widget.view); + final l10n = AppLocalizations.of(context); + + return PlatformScaffold( + appBar: PlatformAppBar( + title: Text(l10n.viewDetails_title), + trailingActions: [ + if (widget.view.alarms.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 (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: locations.last.asCoords(), + ), + ), + ), + ], + ), + ), + ], + 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: widget.view, + ), + ), + ], + ), + ), + ), + ); + } +} 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..28687745 --- /dev/null +++ b/lib/screens/locations_overview_screen_widgets/LocationFetchers.dart @@ -0,0 +1,68 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:locus/services/location_fetcher_service/Fetcher.dart'; +import 'package:locus/services/location_point_service.dart'; +import 'package:locus/services/view_service.dart'; + +class LocationFetchers extends ChangeNotifier { + final Set _fetchers = {}; + + UnmodifiableSetView get fetchers => UnmodifiableSetView(_fetchers); + + LocationFetchers(); + + 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() { + 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/TaskTile.dart b/lib/screens/locations_overview_screen_widgets/TaskTile.dart index 9ebf0395..6b674cb2 100644 --- a/lib/screens/locations_overview_screen_widgets/TaskTile.dart +++ b/lib/screens/locations_overview_screen_widgets/TaskTile.dart @@ -95,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( @@ -117,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..a8eb5957 100644 --- a/lib/screens/locations_overview_screen_widgets/ViewDetails.dart +++ b/lib/screens/locations_overview_screen_widgets/ViewDetails.dart @@ -8,12 +8,15 @@ 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/date.dart'; import 'package:locus/utils/navigation.dart'; import 'package:latlong2/latlong.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 'package:map_launcher/map_launcher.dart'; import '../../constants/spacing.dart'; import '../../services/location_point_service.dart'; import '../../utils/icon.dart'; @@ -21,12 +24,12 @@ import '../../utils/theme.dart'; import '../../widgets/BentoGridElement.dart'; import '../../widgets/LocusFlutterMap.dart'; import '../../widgets/RequestLocationPermissionMixin.dart'; -import '../ViewDetailScreen.dart'; +import '../ViewDetailsScreen.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, @@ -51,9 +54,7 @@ class _ViewDetailsState extends State { oldLastLocation = oldWidget.location; } - Widget buildHeadingMap( - final LocationPointService lastLocation, - ) { + Widget buildHeadingMap(final LocationPointService lastLocation,) { return ClipRRect( borderRadius: BorderRadius.circular(LARGE_SPACE), child: SizedBox( @@ -74,14 +75,15 @@ class _ViewDetailsState extends State { lastLocation.latitude, lastLocation.longitude, ), - builder: (context) => Transform.rotate( - angle: lastLocation.heading!, - child: Icon( - CupertinoIcons.location_north_fill, - color: getPrimaryColorShades(context)[0], - size: 30, - ), - ), + builder: (context) => + Transform.rotate( + angle: lastLocation.heading!, + child: Icon( + CupertinoIcons.location_north_fill, + color: getPrimaryColorShades(context)[0], + size: 30, + ), + ), ), ], ) @@ -115,21 +117,13 @@ class _ViewDetailsState extends State { ), DistanceBentoElement( lastLocation: lastLocation, - onTap: () { - widget.onGoToPosition( - LatLng( - lastLocation.latitude, - lastLocation.longitude, - ), - ); - }, ), BentoGridElement( title: lastLocation.altitude == null ? l10n.unknownValue : l10n.locations_values_altitude_m( - lastLocation.altitude!.round(), - ), + lastLocation.altitude!.round(), + ), icon: platformThemeData( context, material: (_) => Icons.height_rounded, @@ -142,8 +136,8 @@ class _ViewDetailsState extends State { title: lastLocation.speed == null ? l10n.unknownValue : l10n.locations_values_speed_kmh( - (lastLocation.speed! * 3.6).round(), - ), + (lastLocation.speed! * 3.6).round(), + ), icon: platformThemeData( context, material: (_) => Icons.speed, @@ -156,8 +150,8 @@ class _ViewDetailsState extends State { title: lastLocation.batteryLevel == null ? l10n.unknownValue : l10n.locations_values_battery_value( - (lastLocation.batteryLevel! * 100).round(), - ), + (lastLocation.batteryLevel! * 100).round(), + ), icon: getIconDataForBatteryLevel( context, lastLocation.batteryLevel, @@ -169,8 +163,8 @@ class _ViewDetailsState extends State { title: lastLocation.batteryState == null ? l10n.unknownValue : l10n.locations_values_batteryState_value( - lastLocation.batteryState!.name, - ), + lastLocation.batteryState!.name, + ), icon: Icons.cable_rounded, type: BentoType.tertiary, description: l10n.locations_values_batteryState_description, @@ -188,10 +182,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 +201,10 @@ class _DistanceBentoElementState extends State void fetchCurrentPosition() async { _positionStream = getLastAndCurrentPosition(updateLocation: true) ..listen((position) { + if (!mounted) { + return; + } + setState(() { currentPosition = position; }); @@ -242,19 +238,16 @@ 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 +257,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 +311,7 @@ class LastLocationBentoElement extends StatefulWidget { class _LastLocationBentoElementState extends State { late final Timer _timer; + bool showAbsolute = false; @override void initState() { @@ -332,12 +335,13 @@ class _LastLocationBentoElementState extends State { return BentoGridElement( onTap: () { - pushRoute( - context, - (context) => ViewDetailScreen(view: widget.view), - ); + setState(() { + showAbsolute = !showAbsolute; + }); }, - title: GetTimeAgo.parse( + title: showAbsolute + ? formatDateTimeHumanReadable(widget.lastLocation.createdAt) + : GetTimeAgo.parse( DateTime.now().subtract( DateTime.now().difference(widget.lastLocation.createdAt), ), diff --git a/lib/screens/locations_overview_screen_widgets/ViewDetailsSheet.dart b/lib/screens/locations_overview_screen_widgets/ViewDetailsSheet.dart index ca06f0e9..7acb4e6f 100644 --- a/lib/screens/locations_overview_screen_widgets/ViewDetailsSheet.dart +++ b/lib/screens/locations_overview_screen_widgets/ViewDetailsSheet.dart @@ -13,12 +13,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 +141,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; }); diff --git a/lib/screens/shares_overview_screen_widgets/ViewTile.dart b/lib/screens/shares_overview_screen_widgets/ViewTile.dart index 2e0f0d7e..e994cb75 100644 --- a/lib/screens/shares_overview_screen_widgets/ViewTile.dart +++ b/lib/screens/shares_overview_screen_widgets/ViewTile.dart @@ -9,7 +9,7 @@ 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; @@ -76,7 +76,7 @@ class ViewTile extends StatelessWidget { Navigator.of(context).push( NativePageRoute( context: context, - builder: (context) => ViewDetailScreen( + builder: (context) => ViewDetailsScreen( view: view, ), ), diff --git a/lib/screens/task_detail_screen_widgets/Details.dart b/lib/screens/task_detail_screen_widgets/Details.dart index 0a5ace2f..97983ee1 100644 --- a/lib/screens/task_detail_screen_widgets/Details.dart +++ b/lib/screens/task_detail_screen_widgets/Details.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 8e65f4a2..8e4d6b19 100644 --- a/lib/screens/task_detail_screen_widgets/LocationDetails.dart +++ b/lib/screens/task_detail_screen_widgets/LocationDetails.dart @@ -8,6 +8,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' 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'; @@ -90,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/view_alarm_screen_widgets/ViewAlarmSelectRadiusRegionScreen.dart b/lib/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusRegionScreen.dart index 3563dba9..7d67d25e 100644 --- a/lib/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusRegionScreen.dart +++ b/lib/screens/view_alarm_screen_widgets/ViewAlarmSelectRadiusRegionScreen.dart @@ -9,6 +9,7 @@ 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/locations_overview_screen_widgets/constants.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/index.dart'; @@ -18,7 +19,9 @@ 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/GoToMyLocationMapAction.dart'; import 'package:locus/widgets/LocationsMap.dart'; +import 'package:locus/widgets/MapActionsContainer.dart'; import 'package:locus/widgets/RequestNotificationPermissionMixin.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; @@ -48,6 +51,8 @@ class _ViewAlarmSelectRadiusRegionScreenState double previousScale = 1; Stream? _positionStream; + bool isGoingToCurrentPosition = false; + @override void initState() { super.initState(); @@ -79,34 +84,36 @@ class _ViewAlarmSelectRadiusRegionScreenState showHelperSheet( context: context, - builder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.location_addAlarm_radiusBased_help_description), - const SizedBox(height: MEDIUM_SPACE), - Row( + builder: (context) => + Column( + mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.touch_app_rounded), - const SizedBox(width: MEDIUM_SPACE), - Flexible( - child: Text( - l10n.location_addAlarm_radiusBased_help_tapDescription), + 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), + 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, ); @@ -117,8 +124,16 @@ class _ViewAlarmSelectRadiusRegionScreenState return; } + setState(() { + isGoingToCurrentPosition = true; + }); + _positionStream = getLastAndCurrentPosition() ..listen((position) { + setState(() { + isGoingToCurrentPosition = false; + }); + flutterMapController?.move( LatLng(position.latitude, position.longitude), 13, @@ -139,7 +154,8 @@ class _ViewAlarmSelectRadiusRegionScreenState super.dispose(); } - CircleLayer getFlutterMapCircleLayer() => CircleLayer( + CircleLayer getFlutterMapCircleLayer() => + CircleLayer( circles: [ CircleMarker( point: alarmCenter!, @@ -160,14 +176,15 @@ class _ViewAlarmSelectRadiusRegionScreenState isDismissible: true, isScrollControlled: true, ), - builder: (_) => RadiusRegionMetaDataSheet( - center: alarmCenter!, - radius: radius, - ), + builder: (_) => + RadiusRegionMetaDataSheet( + center: alarmCenter!, + radius: radius, + ), ); final hasGrantedNotificationAccess = - await showNotificationPermissionDialog(); + await showNotificationPermissionDialog(); if (!hasGrantedNotificationAccess) { return; @@ -293,53 +310,54 @@ class _ViewAlarmSelectRadiusRegionScreenState ); } + Widget buildMapActions() { + return MapActionsContainer( + children: [ + GoToMyLocationMapAction( + onGoToMyLocation: goToCurrentPosition, + animate: isGoingToCurrentPosition, + ), + ], + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return PlatformScaffold( - material: (_, __) => MaterialScaffoldData( - resizeToAvoidBottomInset: false, - ), + 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, - ), + cupertino: (_, __) => + CupertinoIconButtonData( + padding: EdgeInsets.zero, + ), icon: Icon(context.platformIcons.help), onPressed: showHelp, ), ], - cupertino: (_, __) => CupertinoNavigationBarData( - backgroundColor: isInScaleMode - ? null - : getCupertinoAppBarColorForMapScreen(context), - ), + cupertino: (_, __) => + CupertinoNavigationBarData( + backgroundColor: isInScaleMode + ? null + : getCupertinoAppBarColorForMapScreen(context), + ), ), body: GestureDetector( onScaleUpdate: isInScaleMode ? updateZoom : null, onTap: isInScaleMode ? () { - Vibration.vibrate(duration: 50); - setState(() { - isInScaleMode = false; - }); - } + 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( @@ -352,7 +370,12 @@ class _ViewAlarmSelectRadiusRegionScreenState Positioned.fill( child: IgnorePointer( ignoring: isInScaleMode, - child: buildMap(), + child: Stack( + children: [ + buildMap(), + buildMapActions(), + ], + ), ), ), if (isInScaleMode) ...[ 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..a98e49a3 --- /dev/null +++ b/lib/screens/view_details_screen_widgets/LocationPointsList.dart @@ -0,0 +1,92 @@ +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.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(); + + @override + void initState() { + super.initState(); + + final 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(); + } + } + }); + + if (!fetcher.hasFetchedAllLocations) { + fetcher.fetchMoreLocations(); + } + } + + @override + void dispose() { + final locationFetchers = context.read(); + 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/location_fetcher_service/Fetcher.dart b/lib/services/location_fetcher_service/Fetcher.dart new file mode 100644 index 00000000..ae76bfc8 --- /dev/null +++ b/lib/services/location_fetcher_service/Fetcher.dart @@ -0,0 +1,139 @@ +import 'dart:collection'; + +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.dart'; + +class Fetcher extends ChangeNotifier { + final TaskView view; + final Locations _locations = Locations(); + + final List _getLocationsUnsubscribers = []; + + 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); + + void _getLocations({ + final DateTime? from, + final DateTime? until, + final int? limit, + final VoidCallback? onEmptyEnd, + final VoidCallback? onEnd, + final void Function(LocationPointService)? onLocationFetched, + }) { + _isLoading = true; + + notifyListeners(); + + final unsubscriber = view.getLocations( + limit: limit, + until: until, + from: from, + onLocationFetched: (location) { + if (!_isMounted) { + return; + } + + _locations.add(location); + onLocationFetched?.call(location); + }, + onEnd: () { + if (!_isMounted) { + return; + } + + _isLoading = false; + onEnd?.call(); + notifyListeners(); + }, + onEmptyEnd: () { + if (!_isMounted) { + return; + } + + _isLoading = false; + onEmptyEnd?.call(); + notifyListeners(); + }, + ); + + _getLocationsUnsubscribers.add(unsubscriber); + } + + void fetchPreviewLocations() { + _getLocations( + from: DateTime.now().subtract(const Duration(hours: 24)), + onEnd: () { + if (!_isMounted) { + return; + } + + _hasFetchedPreviewLocations = true; + }, + onEmptyEnd: () { + if (!_isMounted) { + return; + } + + _getLocations( + limit: 1, + onEnd: () { + _hasFetchedPreviewLocations = true; + }, + onEmptyEnd: () { + _hasFetchedPreviewLocations = true; + }, + ); + }, + ); + } + + void fetchMoreLocations([ + int limit = 50, + ]) { + final earliestLocation = _locations.sortedLocations.first; + + _getLocations( + limit: limit, + until: earliestLocation.createdAt, + onEmptyEnd: () { + if (!_isMounted) { + return; + } + + _hasFetchedAllLocations = true; + }, + ); + } + + void fetchAllLocations() { + _getLocations(); + } + + @override + void dispose() { + _isMounted = false; + + for (final unsubscriber in _getLocationsUnsubscribers) { + unsubscriber(); + } + + 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 517a4fd1..043d584e 100644 --- a/lib/services/location_point_service.dart +++ b/lib/services/location_point_service.dart @@ -7,6 +7,7 @@ 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(); @@ -40,18 +41,31 @@ class LocationPointService { double? batteryLevel, this.isCopy = false, this.batteryState, - }) : altitude = altitude == 0.0 ? null : altitude, + }) + : altitude = altitude == 0.0 ? null : altitude, speed = speed == 0.0 ? null : speed, speedAccuracy = speedAccuracy == 0.0 ? null : speedAccuracy, heading = heading == 0.0 ? null : heading, 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)}"; factory LocationPointService.dummyFromLatLng(final LatLng latLng, - {final double accuracy = 10.0}) => + {final double accuracy = 10.0}) => LocationPointService( id: uuid.v4(), createdAt: DateTime.now(), @@ -61,9 +75,9 @@ class LocationPointService { ); static Future fromLocationDto( - final LocationDto locationDto, [ - final bool addBatteryInfo = true, - ]) async { + final LocationDto locationDto, [ + final bool addBatteryInfo = true, + ]) async { BatteryInfo? batteryInfo; if (addBatteryInfo) { @@ -111,8 +125,8 @@ class LocationPointService { batteryState: json["batteryState"] == null ? null : BatteryState.values.firstWhere( - (value) => value.name == json["batteryState"], - ), + (value) => value.name == json["batteryState"], + ), ); } @@ -134,8 +148,7 @@ class LocationPointService { } static Future fromPosition( - final Position position, - ) async { + final Position position,) async { BatteryInfo? batteryInfo; try { @@ -165,7 +178,8 @@ class LocationPointService { /// Copies `current` with a new id - mainly used in conjunction with `createUsingCurrentLocation` /// in background fetch to avoid fetching the location multiple times. - LocationPointService copyWithDifferentId() => LocationPointService( + LocationPointService copyWithDifferentId() => + LocationPointService( id: uuid.v4(), createdAt: DateTime.now(), latitude: latitude, @@ -179,10 +193,8 @@ class LocationPointService { batteryState: batteryState, ); - static Future fromEncrypted( - final String cipherText, - final SecretKey encryptionPassword, - ) async { + static Future fromEncrypted(final String cipherText, + final SecretKey encryptionPassword,) async { final message = await decryptUsingAES( cipherText, encryptionPassword, @@ -191,7 +203,8 @@ class LocationPointService { return LocationPointService.fromJSON(jsonDecode(message)); } - Position asPosition() => Position( + Position asPosition() => + Position( latitude: latitude, longitude: longitude, altitude: altitude ?? 0.0, @@ -204,6 +217,8 @@ class LocationPointService { LatLng asLatLng() => LatLng(latitude, longitude); + Coords asCoords() => Coords(latitude, longitude); + LocationPointService copyWith({ final double? latitude, final double? longitude, diff --git a/lib/services/view_service.dart b/lib/services/view_service.dart index 993b8152..59db0de4 100644 --- a/lib/services/view_service.dart +++ b/lib/services/view_service.dart @@ -239,6 +239,7 @@ class TaskView extends ChangeNotifier with LocationBase { final VoidCallback? onEmptyEnd, int? limit, DateTime? from, + DateTime? until, }) => get_locations_api.getLocations( encryptionPassword: _encryptionPassword, @@ -249,6 +250,7 @@ class TaskView extends ChangeNotifier with LocationBase { onEmptyEnd: onEmptyEnd, from: from, limit: limit, + until: until, ); Future> getLocationsAsFuture({ 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/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/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/pubspec.lock b/pubspec.lock index ee87fe83..61de5a13 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1478,10 +1478,10 @@ packages: dependency: transitive description: name: tuple - sha256: "0ea99cd2f9352b2586583ab2ce6489d1f95a5f6de6fb9492faaf97ae2060f0aa" + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cf7d980b..5bf05317 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 0.14.3+35 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