diff --git a/example/lib/main.dart b/example/lib/main.dart index ebf768d0..2f326f96 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -13,6 +13,7 @@ import 'src/shared/misc/shared_preferences.dart'; import 'src/shared/state/download_configuration_provider.dart'; import 'src/shared/state/download_provider.dart'; import 'src/shared/state/general_provider.dart'; +import 'src/shared/state/recoverable_regions_provider.dart'; import 'src/shared/state/region_selection_provider.dart'; void main() async { @@ -95,6 +96,7 @@ class _AppContainer extends StatelessWidget { ), ChangeNotifierProvider( create: (_) => ExportSelectionProvider(), + lazy: true, ), ChangeNotifierProvider( create: (_) => RegionSelectionProvider(), @@ -105,6 +107,10 @@ class _AppContainer extends StatelessWidget { ), ChangeNotifierProvider( create: (_) => DownloadingProvider(), + // cannot be lazy as must persist when user disposed + ), + ChangeNotifierProvider( + create: (_) => RecoverableRegionsProvider(), ), ], child: MaterialApp( diff --git a/example/lib/src/screens/main/main.dart b/example/lib/src/screens/main/main.dart index 92f680b4..e6b6961f 100644 --- a/example/lib/src/screens/main/main.dart +++ b/example/lib/src/screens/main/main.dart @@ -1,7 +1,14 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; +import '../../shared/state/download_provider.dart'; +import '../../shared/state/recoverable_regions_provider.dart'; import '../../shared/state/region_selection_provider.dart'; +import '../../shared/state/selected_tab_state.dart'; import 'map_view/components/bottom_sheet_wrapper.dart'; import 'map_view/map_view.dart'; import 'secondary_view/contents/region_selection/components/shared/to_config_method.dart'; @@ -19,172 +26,240 @@ class MainScreen extends StatefulWidget { } class _MainScreenState extends State { - final bottomSheetOuterController = DraggableScrollableController(); + final _bottomSheetOuterController = DraggableScrollableController(); + + StreamSubscription>>? + _failedRegionsStreamSub; + + @override + void initState() { + super.initState(); + _failedRegionsStreamSub = FMTCRoot.recovery + .watch(triggerImmediately: true) + .asyncMap( + (_) async => (await FMTCRoot.recovery.recoverableRegions).failedOnly, + ) + .listen( + (failedRegions) { + if (!mounted) return; + context.read().failedRegions = + Map.fromEntries( + failedRegions.map( + (r) { + final region = r.cast(); + final existingColor = context + .read() + .failedRegions[region]; + return MapEntry( + region, + existingColor ?? + HSLColor.fromColor( + Colors.primaries[ + Random().nextInt(Colors.primaries.length - 1)], + ), + ); + }, + ), + ); + }, + ); + } - int selectedTab = 0; + @override + void dispose() { + _failedRegionsStreamSub?.cancel(); + super.dispose(); + } @override - Widget build(BuildContext context) { - final mapMode = switch (selectedTab) { - 0 => MapViewMode.standard, - 1 => MapViewMode.downloadRegion, - _ => throw UnimplementedError(), - }; + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: selectedTabState, + builder: (context, selectedTab, child) { + final mapMode = switch (selectedTab) { + 0 => MapViewMode.standard, + 1 => MapViewMode.downloadRegion, + 2 => MapViewMode.recovery, + _ => throw UnimplementedError(), + }; - return LayoutBuilder( - builder: (context, constraints) { - final layoutDirection = - constraints.maxWidth < 1200 ? Axis.vertical : Axis.horizontal; + return LayoutBuilder( + builder: (context, constraints) { + final layoutDirection = + constraints.maxWidth < 1200 ? Axis.vertical : Axis.horizontal; - if (layoutDirection == Axis.vertical) { - return Scaffold( - body: BottomSheetMapWrapper( - bottomSheetOuterController: bottomSheetOuterController, - mode: mapMode, - layoutDirection: layoutDirection, - ), - bottomSheet: SecondaryViewBottomSheet( - selectedTab: selectedTab, - controller: bottomSheetOuterController, - ), - floatingActionButton: selectedTab == 1 && - context - .watch() - .constructedRegions - .isNotEmpty - ? DelayedControllerAttachmentBuilder( - listenable: bottomSheetOuterController, - builder: (context, _) => AnimatedBuilder( - animation: bottomSheetOuterController, - builder: (context, _) => FloatingActionButton( - onPressed: () async { - final currentPx = bottomSheetOuterController.pixels; - await bottomSheetOuterController.animateTo( - 2 / 3, + if (layoutDirection == Axis.vertical) { + return Scaffold( + body: BottomSheetMapWrapper( + bottomSheetOuterController: _bottomSheetOuterController, + mode: mapMode, + layoutDirection: layoutDirection, + ), + bottomSheet: SecondaryViewBottomSheet( + selectedTab: selectedTab, + controller: _bottomSheetOuterController, + ), + floatingActionButton: selectedTab == 1 && + context + .watch() + .constructedRegions + .isNotEmpty + ? DelayedControllerAttachmentBuilder( + listenable: _bottomSheetOuterController, + builder: (context, _) => AnimatedBuilder( + animation: _bottomSheetOuterController, + builder: (context, _) => FloatingActionButton( + onPressed: () async { + final currentPx = + _bottomSheetOuterController.pixels; + await _bottomSheetOuterController.animateTo( + 2 / 3, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + if (!context.mounted) return; + prepareDownloadConfigView( + context, + shouldShowConfig: currentPx > 33, + ); + }, + tooltip: _bottomSheetOuterController.pixels <= 33 + ? 'Show regions' + : 'Configure download', + child: _bottomSheetOuterController.pixels <= 33 + ? const Icon(Icons.library_add_check) + : const Icon(Icons.tune), + ), + ), + ) + : null, + bottomNavigationBar: NavigationBar( + selectedIndex: selectedTab, + destinations: [ + const NavigationDestination( + icon: Icon(Icons.map_outlined), + selectedIcon: Icon(Icons.map), + label: 'Map', + ), + const NavigationDestination( + icon: Icon(Icons.download_outlined), + selectedIcon: Icon(Icons.download), + label: 'Download', + ), + NavigationDestination( + icon: Selector( + selector: (context, provider) => + provider.failedRegions.length, + builder: (context, count, child) => count == 0 + ? child! + : Badge.count(count: count, child: child), + child: const Icon(Icons.support_outlined), + ), + selectedIcon: const Icon(Icons.support), + label: 'Recovery', + ), + ], + onDestinationSelected: (i) { + selectedTabState.value = i; + if (i == 1) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _bottomSheetOuterController.animateTo( + 32 / constraints.maxHeight, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, - ); - if (!context.mounted) return; - prepareDownloadConfigView( - context, - shouldMoveTo: currentPx > 33, - ); - }, - tooltip: bottomSheetOuterController.pixels <= 33 - ? 'Show regions' - : 'Configure download', - child: bottomSheetOuterController.pixels <= 33 - ? const Icon(Icons.library_add_check) - : const Icon(Icons.tune), - ), - ), - ) - : null, - bottomNavigationBar: NavigationBar( - selectedIndex: selectedTab, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.map_outlined), - selectedIcon: Icon(Icons.map), - label: 'Map', - ), - NavigationDestination( - icon: Icon(Icons.download_outlined), - selectedIcon: Icon(Icons.download), - label: 'Download', - ), - NavigationDestination( - icon: Icon(Icons.support_outlined), - selectedIcon: Icon(Icons.support), - label: 'Recovery', - ), - ], - onDestinationSelected: (i) { - setState(() => selectedTab = i); - if (i == 1) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => bottomSheetOuterController.animateTo( - 32 / constraints.maxHeight, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ), - ); - } else { - WidgetsBinding.instance.addPostFrameCallback( - (_) => bottomSheetOuterController.animateTo( - 0.3, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ), - ); - } - }, - ), - ); - } + ), + ); + } else { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _bottomSheetOuterController.animateTo( + 0.3, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ), + ); + } + }, + ), + ); + } - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - body: LayoutBuilder( - builder: (context, constraints) => Row( - children: [ - NavigationRail( - backgroundColor: Colors.transparent, - destinations: const [ - NavigationRailDestination( - icon: Icon(Icons.map_outlined), - selectedIcon: Icon(Icons.map), - label: Text('Map'), - ), - NavigationRailDestination( - icon: Icon(Icons.download_outlined), - selectedIcon: Icon(Icons.download), - label: Text('Download'), - ), - NavigationRailDestination( - icon: Icon(Icons.support_outlined), - selectedIcon: Icon(Icons.support), - label: Text('Recovery'), - ), - ], - selectedIndex: selectedTab, - labelType: NavigationRailLabelType.all, - leading: Padding( - padding: const EdgeInsets.only(top: 8, bottom: 16), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.asset( - 'assets/icons/ProjectIcon.png', - width: 54, - height: 54, - filterQuality: FilterQuality.high, + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + body: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + NavigationRail( + backgroundColor: Colors.transparent, + destinations: [ + const NavigationRailDestination( + icon: Icon(Icons.map_outlined), + selectedIcon: Icon(Icons.map), + label: Text('Map'), + ), + NavigationRailDestination( + icon: Selector( + selector: (context, provider) => + provider.isDownloading, + builder: (context, isDownloading, child) => + !isDownloading ? child! : Badge(child: child), + child: const Icon(Icons.download_outlined), + ), + selectedIcon: const Icon(Icons.download), + label: const Text('Download'), + ), + NavigationRailDestination( + icon: Selector( + selector: (context, provider) => + provider.failedRegions.length, + builder: (context, count, child) => count == 0 + ? child! + : Badge.count(count: count, child: child), + child: const Icon(Icons.support_outlined), + ), + selectedIcon: const Icon(Icons.support), + label: const Text('Recovery'), + ), + ], + selectedIndex: selectedTab, + labelType: NavigationRailLabelType.all, + leading: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset( + 'assets/icons/ProjectIcon.png', + width: 54, + height: 54, + filterQuality: FilterQuality.high, + ), + ), + ), + onDestinationSelected: (i) => + selectedTabState.value = i, ), - ), - ), - onDestinationSelected: (i) => setState(() => selectedTab = i), - ), - SecondaryViewSide( - selectedTab: selectedTab, - constraints: constraints, - ), - Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - bottomLeft: Radius.circular(16), - ), - child: MapView( - bottomSheetOuterController: bottomSheetOuterController, - mode: mapMode, - layoutDirection: layoutDirection, - ), + SecondaryViewSide( + selectedTab: selectedTab, + constraints: constraints, + ), + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + child: MapView( + bottomSheetOuterController: + _bottomSheetOuterController, + mode: mapMode, + layoutDirection: layoutDirection, + ), + ), + ), + ], ), ), - ], - ), - ), - ); - }, - ); - } + ); + }, + ); + }, + ); } diff --git a/example/lib/src/screens/main/map_view/components/recovery_regions/recovery_regions.dart b/example/lib/src/screens/main/map_view/components/recovery_regions/recovery_regions.dart new file mode 100644 index 00000000..6566c6d8 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/recovery_regions/recovery_regions.dart @@ -0,0 +1,52 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../shared/state/recoverable_regions_provider.dart'; + +class RecoveryRegions extends StatelessWidget { + const RecoveryRegions({super.key}); + + @override + Widget build(BuildContext context) => Consumer( + builder: (context, provider, _) { + final map = + > pointss, String label})>>{}; + for (final MapEntry(key: region, value: color) + in provider.failedRegions.entries) { + (map[color] ??= []).add( + ( + pointss: region.region.regions + .map((e) => e.toOutline().toList()) + .toList(), + label: "To '${region.storeName}'", + ), + ); + } + + return PolygonLayer( + polygons: map.entries + .map( + (e) => e.value + .map( + (region) => region.pointss.map( + (points) => Polygon( + points: points, + color: e.key.toColor().withAlpha(255 ~/ 2), + borderColor: e.key.toColor(), + borderStrokeWidth: 2, + label: region.label, + labelPlacement: PolygonLabelPlacement.polylabel, + ), + ), + ) + .flattened, + ) + .flattened + .toList(), + ); + }, + ); +} diff --git a/example/lib/src/screens/main/map_view/map_view.dart b/example/lib/src/screens/main/map_view/map_view.dart index 9fa5d60a..f89e81ac 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -19,6 +19,7 @@ import '../../../shared/state/region_selection_provider.dart'; import 'components/additional_overlay/additional_overlay.dart'; import 'components/debugging_tile_builder/debugging_tile_builder.dart'; //import 'components/download_progress/download_progress_masker.dart'; +import 'components/recovery_regions/recovery_regions.dart'; import 'components/region_selection/crosshairs.dart'; import 'components/region_selection/custom_polygon_snapping_indicator.dart'; import 'components/region_selection/region_shape.dart'; @@ -26,6 +27,7 @@ import 'components/region_selection/region_shape.dart'; enum MapViewMode { standard, downloadRegion, + recovery, } class MapView extends StatefulWidget { @@ -369,6 +371,8 @@ class _MapViewState extends State with TickerProviderStateMixin { const RegionShape(), const CustomPolygonSnappingIndicator(), ], + if (widget.mode == MapViewMode.recovery) + const RecoveryRegions(), if (widget.bottomPaddingWrapperBuilder case final bpwb?) Builder(builder: (context) => bpwb(context, attribution)) else @@ -381,21 +385,26 @@ class _MapViewState extends State with TickerProviderStateMixin { children: [ MouseRegion( opaque: false, - cursor: widget.mode == MapViewMode.standard || - context.select( - (p) => p.isDownloadSetupPanelVisible, - ) || - context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter - ? MouseCursor.defer - : context.select( + cursor: switch (widget.mode) { + MapViewMode.standard => MouseCursor.defer, + MapViewMode.recovery => MouseCursor.defer, + MapViewMode.downloadRegion + when context.select( + (p) => p.isDownloadSetupPanelVisible, + ) || + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter => + MouseCursor.defer, + MapViewMode.downloadRegion + when context.select( (p) => p.customPolygonSnap, - ) - ? SystemMouseCursors.none - : SystemMouseCursors.precise, + ) => + SystemMouseCursors.none, + MapViewMode.downloadRegion => SystemMouseCursors.precise, + }, child: map, ), if (isCrosshairsVisible) const Center(child: Crosshairs()), diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/store_selector.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/store_selector.dart index 4c02050a..5f9e4e56 100644 --- a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/store_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/store_selector.dart @@ -10,10 +10,12 @@ class StoreSelector extends StatefulWidget { super.key, this.storeName, required this.onStoreNameSelected, + this.enabled = true, }); final String? storeName; final void Function(String?) onStoreNameSelected; + final bool enabled; @override State createState() => _StoreSelectorState(); @@ -70,6 +72,7 @@ class _StoreSelectorState extends State { filled: true, helperMaxLines: 2, ), + enabled: widget.enabled, ); }, ), diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/config_options.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/config_options.dart index aef10680..8c9ec803 100644 --- a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/config_options.dart +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/config_options.dart @@ -40,6 +40,8 @@ class _ConfigOptionsState extends State { context.select( (p) => p.retryFailedRequestTiles, ); + final fromRecovery = context + .select((p) => p.fromRecovery); return SingleChildScrollView( child: Column( @@ -49,6 +51,7 @@ class _ConfigOptionsState extends State { onStoreNameSelected: (storeName) => context .read() .selectedStoreName = storeName, + enabled: fromRecovery == null, ), const Divider(height: 24), Row( @@ -60,8 +63,9 @@ class _ConfigOptionsState extends State { values: RangeValues(minZoom.toDouble(), maxZoom.toDouble()), max: 20, divisions: 20, - onChanged: (r) => - context.read() + onChanged: fromRecovery != null + ? null + : (r) => context.read() ..minZoom = r.start.toInt() ..maxZoom = r.end.toInt(), ), diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/confirmation_panel/confirmation_panel.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/confirmation_panel/confirmation_panel.dart index fd924319..b5b954ff 100644 --- a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/confirmation_panel/confirmation_panel.dart +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/confirmation_panel/confirmation_panel.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; @@ -43,6 +45,9 @@ class _ConfirmationPanelState extends State { (p) => p.selectedStoreName, ) != null; + final fromRecovery = context.select( + (p) => p.fromRecovery, + ); final tileCountableRegion = MultiRegion(regions).toDownloadable( minZoom: minZoom, @@ -100,7 +105,7 @@ class _ConfirmationPanelState extends State { else Text( NumberFormat.decimalPatternDigits(decimalDigits: 0) - .format(snapshot.data), + .format(snapshot.requireData), style: Theme.of(context) .textTheme .headlineLarge! @@ -190,6 +195,13 @@ class _ConfirmationPanelState extends State { icon: _loadingDownloader ? null : const Icon(Icons.download), ), ), + if (fromRecovery != null) ...[ + const SizedBox(height: 4), + Text( + 'This will delete the recoverable region', + style: Theme.of(context).textTheme.labelSmall, + ), + ], ], ), ); @@ -243,6 +255,11 @@ class _ConfirmationPanelState extends State { downloadStreams: downloadStreams, ); + if (downloadConfiguration.fromRecovery case final recoveryId?) { + unawaited(FMTCRoot.recovery.cancel(recoveryId)); + downloadConfiguration.fromRecovery = null; + } + // The downloading view is switched to by `assignDownload`, when the first // event is recieved from the stream (indicating the preparation is // complete and the download is starting). diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_side.dart index 9673e0ff..db1d7c31 100644 --- a/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_side.dart +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_side.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../../../../shared/state/download_configuration_provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; +import '../../../../../shared/state/selected_tab_state.dart'; import '../../layouts/side/components/panel.dart'; import 'components/config_options/config_options.dart'; import 'components/confirmation_panel/confirmation_panel.dart'; @@ -22,9 +24,19 @@ class DownloadConfigurationViewSide extends StatelessWidget { padding: const EdgeInsets.all(4), child: IconButton( onPressed: () { - context - .read() - .isDownloadSetupPanelVisible = false; + final regionSelectionProvider = + context.read(); + final downloadConfigProvider = + context.read(); + + regionSelectionProvider.isDownloadSetupPanelVisible = false; + + if (downloadConfigProvider.fromRecovery == null) return; + + regionSelectionProvider.clearConstructedRegions(); + downloadConfigProvider.fromRecovery = null; + + selectedTabState.value = 2; }, icon: const Icon(Icons.arrow_back), tooltip: 'Return to selection', diff --git a/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/components/no_regions.dart b/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/components/no_regions.dart new file mode 100644 index 00000000..f395f1d3 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/components/no_regions.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class NoRegions extends StatelessWidget { + const NoRegions({super.key}); + + @override + Widget build(BuildContext context) => SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.auto_fix_off, size: 42), + const SizedBox(height: 12), + Text( + 'No failed downloads', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + const Text( + "If a download fails unexpectedly, it'll appear here! You can " + 'then finish the end of the download.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/recoverable_regions_list.dart b/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/recoverable_regions_list.dart new file mode 100644 index 00000000..1a5208d0 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/recoverable_regions_list.dart @@ -0,0 +1,196 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_animations/flutter_map_animations.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../shared/state/download_configuration_provider.dart'; +import '../../../../../../../shared/state/download_provider.dart'; +import '../../../../../../../shared/state/general_provider.dart'; +import '../../../../../../../shared/state/recoverable_regions_provider.dart'; +import '../../../../../../../shared/state/region_selection_provider.dart'; +import '../../../../../../../shared/state/selected_tab_state.dart'; +import '../../../region_selection/components/shared/to_config_method.dart'; +import 'components/no_regions.dart'; + +class RecoverableRegionsList extends StatefulWidget { + const RecoverableRegionsList({super.key}); + + @override + State createState() => _RecoverableRegionsListState(); +} + +class _RecoverableRegionsListState extends State { + bool _preventCameraReturnFlag = false; + (LatLng, double)? _initialMapPosition; + AnimatedMapController? _animatedMapController; + StreamSubscription? _mapEventStreamSub; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _animatedMapController ??= + context.read().animatedMapController; + + _mapEventStreamSub ??= + _animatedMapController!.mapController.mapEventStream.listen((evt) { + if (AnimationId.fromMapEvent(evt) != null) return; + _preventCameraReturnFlag = true; + _mapEventStreamSub!.cancel(); + }); + + _initialMapPosition ??= ( + _animatedMapController!.mapController.camera.center, + _animatedMapController!.mapController.camera.zoom, + ); + + final failedRegions = + context.read().failedRegions.keys; + if (failedRegions.isEmpty) return; + + final bounds = LatLngBounds.fromPoints( + failedRegions.first.region.regions.first + .toOutline() + .toList(growable: false), + ); + for (final region in failedRegions + .map((failedRegion) => failedRegion.region.regions) + .flattened) { + bounds.extendBounds( + LatLngBounds.fromPoints(region.toOutline().toList(growable: false)), + ); + } + _animatedMapController!.animatedFitCamera( + cameraFit: CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(16) + + MediaQueryData.fromView(View.of(context)).padding + + const EdgeInsets.only(bottom: 18), + ), + ); + } + + @override + void dispose() { + if (!_preventCameraReturnFlag) { + _animatedMapController!.animateTo( + dest: _initialMapPosition!.$1, + zoom: _initialMapPosition!.$2, + ); + } + _mapEventStreamSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Selector, HSLColor>>( + selector: (context, provider) => provider.failedRegions, + builder: (context, failedRegions, _) { + if (failedRegions.isEmpty) return const NoRegions(); + + return SliverList.builder( + itemCount: failedRegions.length, + itemBuilder: (context, index) { + final failedRegion = failedRegions.keys.elementAt(index); + final color = failedRegions.values.elementAt(index); + + return ListTile( + leading: Icon(Icons.shape_line, color: color.toColor()), + title: Text("To '${failedRegion.storeName}'"), + subtitle: Text( + '${failedRegion.time.toLocal()}\n' + '${failedRegion.end - failedRegion.start + 1} remaining tiles', + ), + isThreeLine: true, + trailing: IntrinsicHeight( + child: Selector( + selector: (context, provider) => provider.fromRecovery, + builder: (context, fromRecovery, _) { + if (fromRecovery == failedRegion.id) { + return SizedBox( + height: 40, + child: FilledButton.icon( + onPressed: () { + _preventCameraReturnFlag = true; + selectedTabState.value = 1; + prepareDownloadConfigView(context); + }, + icon: const Icon(Icons.tune), + label: const Text('View In Configurator'), + ), + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + IconButton( + onPressed: () => + FMTCRoot.recovery.cancel(failedRegion.id), + icon: const Icon(Icons.delete_forever), + tooltip: 'Delete', + ), + SizedBox( + height: double.infinity, + child: Selector( + selector: (context, provider) => + provider.isDownloading, + builder: (context, isDownloading, _) => + Selector( + selector: (context, provider) => + provider.constructedRegions.isNotEmpty, + builder: (context, isConstructingRegion, _) { + final cannotResume = + isConstructingRegion || isDownloading; + final button = FilledButton.tonalIcon( + onPressed: cannotResume + ? null + : () => _resumeDownload(failedRegion), + icon: const Icon(Icons.download), + label: const Text('Resume'), + ); + if (!cannotResume) return button; + return Tooltip( + message: 'Cannot start another download', + child: button, + ); + }, + ), + ), + ), + ], + ); + }, + ), + ), + ); + }, + ); + }, + ); + + void _resumeDownload(RecoveredRegion failedRegion) { + final regionSelectionProvider = context.read() + ..clearCoordinates(); + failedRegion.region.regions + .forEach(regionSelectionProvider.addConstructedRegion); + context.read() + ..selectedStoreName = failedRegion.storeName + ..minZoom = failedRegion.minZoom + ..maxZoom = failedRegion.maxZoom + ..startTile = failedRegion.start + ..endTile = failedRegion.end + ..fromRecovery = failedRegion.id; + + _preventCameraReturnFlag = true; + selectedTabState.value = 1; + prepareDownloadConfigView(context); + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/recovery/recovery_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/recovery/recovery_view_bottom_sheet.dart new file mode 100644 index 00000000..8ab817eb --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/recovery/recovery_view_bottom_sheet.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; +import '../../layouts/bottom_sheet/utils/tab_header.dart'; +import 'components/recoverable_regions_list/recoverable_regions_list.dart'; + +class RecoveryViewBottomSheet extends StatelessWidget { + const RecoveryViewBottomSheet({super.key}); + + @override + Widget build(BuildContext context) => CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + slivers: const [ + TabHeader(title: 'Recovery'), + SliverToBoxAdapter(child: SizedBox(height: 6)), + RecoverableRegionsList(), + SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/recovery/recovery_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/recovery/recovery_view_side.dart new file mode 100644 index 00000000..8df3c197 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/recovery/recovery_view_side.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import 'components/recoverable_regions_list/recoverable_regions_list.dart'; + +class RecoveryViewSide extends StatelessWidget { + const RecoveryViewSide({super.key}); + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + color: Theme.of(context).colorScheme.surface, + ), + width: double.infinity, + height: double.infinity, + child: const CustomScrollView( + slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: 16, bottom: 16), + sliver: RecoverableRegionsList(), + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shared/to_config_method.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shared/to_config_method.dart index 00d6bcd9..3639a6ba 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shared/to_config_method.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shared/to_config_method.dart @@ -7,7 +7,7 @@ import '../../../../../../../shared/state/region_selection_provider.dart'; void prepareDownloadConfigView( BuildContext context, { - bool shouldMoveTo = true, + bool shouldShowConfig = true, }) { final regionSelectionProvider = context.read(); @@ -32,7 +32,7 @@ void prepareDownloadConfigView( ), ); - if (shouldMoveTo) { + if (shouldShowConfig) { regionSelectionProvider.isDownloadSetupPanelVisible = true; } } diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/bottom_sheet.dart index c6c83142..e4e36927 100644 --- a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/bottom_sheet.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; import '../../contents/download_configuration/download_configuration_view_bottom_sheet.dart'; import '../../contents/home/home_view_bottom_sheet.dart'; +import '../../contents/recovery/recovery_view_bottom_sheet.dart'; import '../../contents/region_selection/region_selection_view_bottom_sheet.dart'; import 'components/delayed_frame_attached_dependent_builder.dart'; import 'components/scrollable_provider.dart'; @@ -69,7 +70,7 @@ class _SecondaryViewBottomSheetState extends State { builder: (context, _) => SizedBox( height: paddingPusherHeight, child: AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, color: innerController.hasClients && innerController.offset != 0 @@ -99,6 +100,7 @@ class _SecondaryViewBottomSheetState extends State { ) ? const DownloadConfigurationViewBottomSheet() : const RegionSelectionViewBottomSheet(), + 2 => const RecoveryViewBottomSheet(), _ => Placeholder(key: ValueKey(widget.selectedTab)), }, ), diff --git a/example/lib/src/screens/main/secondary_view/layouts/side/side.dart b/example/lib/src/screens/main/secondary_view/layouts/side/side.dart index 60efc8cc..41de67d6 100644 --- a/example/lib/src/screens/main/secondary_view/layouts/side/side.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/side/side.dart @@ -6,6 +6,7 @@ import '../../../../../shared/state/region_selection_provider.dart'; import '../../contents/download_configuration/download_configuration_view_side.dart'; import '../../contents/downloading/downloading_view_side.dart'; import '../../contents/home/home_view_side.dart'; +import '../../contents/recovery/recovery_view_side.dart'; import '../../contents/region_selection/region_selection_view_side.dart'; class SecondaryViewSide extends StatelessWidget { @@ -24,7 +25,7 @@ class SecondaryViewSide extends StatelessWidget { child: SizedBox( width: (constraints.maxWidth / 3).clamp(440, 560), child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 150), switchInCurve: Curves.easeIn, switchOutCurve: Curves.easeOut, transitionBuilder: (child, animation) => ScaleTransition( @@ -53,6 +54,7 @@ class SecondaryViewSide extends StatelessWidget { ) ? const DownloadConfigurationViewSide() : const RegionSelectionViewSide(), + 2 => const RecoveryViewSide(), _ => Placeholder(key: ValueKey(selectedTab)), }, ), diff --git a/example/lib/src/shared/state/download_configuration_provider.dart b/example/lib/src/shared/state/download_configuration_provider.dart index ed90df5c..f5f56db5 100644 --- a/example/lib/src/shared/state/download_configuration_provider.dart +++ b/example/lib/src/shared/state/download_configuration_provider.dart @@ -12,6 +12,7 @@ class DownloadConfigurationProvider extends ChangeNotifier { skipExistingTiles: false, skipSeaTiles: true, retryFailedRequestTiles: true, + fromRecovery: null, ); int _minZoom = defaultValues.minZoom; @@ -38,7 +39,7 @@ class DownloadConfigurationProvider extends ChangeNotifier { int? _endTile = defaultValues.endTile; int? get endTile => _endTile; set endTile(int? newNum) { - _endTile = endTile; + _endTile = newNum; notifyListeners(); } @@ -90,4 +91,18 @@ class DownloadConfigurationProvider extends ChangeNotifier { _selectedStoreName = newStoreName; notifyListeners(); } + + int? _fromRecovery = defaultValues.fromRecovery; + int? get fromRecovery => _fromRecovery; + set fromRecovery(int? newState) { + _fromRecovery = newState; + if (newState == null) { + selectedStoreName = null; + startTile = DownloadConfigurationProvider.defaultValues.startTile; + endTile = DownloadConfigurationProvider.defaultValues.endTile; + minZoom = DownloadConfigurationProvider.defaultValues.minZoom; + maxZoom = DownloadConfigurationProvider.defaultValues.maxZoom; + } + notifyListeners(); + } } diff --git a/example/lib/src/shared/state/download_provider.dart b/example/lib/src/shared/state/download_provider.dart index 47e0ab9f..60a09c54 100644 --- a/example/lib/src/shared/state/download_provider.dart +++ b/example/lib/src/shared/state/download_provider.dart @@ -13,6 +13,9 @@ class DownloadingProvider extends ChangeNotifier { bool _isComplete = false; bool get isComplete => _isComplete; + bool _isDownloading = false; + bool get isDownloading => _isDownloading; + DownloadableRegion? _downloadableRegion; DownloadableRegion get downloadableRegion => _downloadableRegion ?? (throw _notReadyError); @@ -40,6 +43,7 @@ class DownloadingProvider extends ChangeNotifier { }) { _storeName = storeName; _downloadableRegion = downloadableRegion; + _isDownloading = true; _rawTileEventsStream = downloadStreams.tileEvents.asBroadcastStream(); @@ -82,6 +86,7 @@ class DownloadingProvider extends ChangeNotifier { void reset() { _isFocused = false; _isComplete = false; + _isDownloading = false; notifyListeners(); } diff --git a/example/lib/src/shared/state/recoverable_regions_provider.dart b/example/lib/src/shared/state/recoverable_regions_provider.dart new file mode 100644 index 00000000..f60609f3 --- /dev/null +++ b/example/lib/src/shared/state/recoverable_regions_provider.dart @@ -0,0 +1,15 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +class RecoverableRegionsProvider extends ChangeNotifier { + var _failedRegions = , HSLColor>{}; + UnmodifiableMapView, HSLColor> + get failedRegions => UnmodifiableMapView(_failedRegions); + set failedRegions(Map, HSLColor> newState) { + _failedRegions = newState; + notifyListeners(); + } +} diff --git a/example/lib/src/shared/state/selected_tab_state.dart b/example/lib/src/shared/state/selected_tab_state.dart new file mode 100644 index 00000000..d285157f --- /dev/null +++ b/example/lib/src/shared/state/selected_tab_state.dart @@ -0,0 +1,3 @@ +import 'package:flutter/foundation.dart'; + +final selectedTabState = ValueNotifier(0); diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9ff932d2..8042be3a 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: async: ^2.12.0 auto_size_text: ^3.0.0 + badges: ^3.1.2 collection: ^1.18.0 file_picker: 8.1.4 # Compatible with 3.27! flutter: diff --git a/lib/src/regions/downloadable_region.dart b/lib/src/regions/downloadable_region.dart index ee2bb9ac..798f7838 100644 --- a/lib/src/regions/downloadable_region.dart +++ b/lib/src/regions/downloadable_region.dart @@ -60,8 +60,10 @@ class DownloadableRegion { final Crs crs; /// Cast [originalRegion] from [R] to [N] + /// + /// Throws if uncastable. @optionalTypeArgs - DownloadableRegion _cast() => DownloadableRegion._( + DownloadableRegion cast() => DownloadableRegion._( originalRegion as N, minZoom: minZoom, maxZoom: maxZoom, @@ -105,11 +107,11 @@ class DownloadableRegion { T Function(DownloadableRegion multi)? multi, }) => switch (originalRegion) { - RectangleRegion() => rectangle?.call(_cast()), - CircleRegion() => circle?.call(_cast()), - LineRegion() => line?.call(_cast()), - CustomPolygonRegion() => customPolygon?.call(_cast()), - MultiRegion() => multi?.call(_cast()), + RectangleRegion() => rectangle?.call(cast()), + CircleRegion() => circle?.call(cast()), + LineRegion() => line?.call(cast()), + CustomPolygonRegion() => customPolygon?.call(cast()), + MultiRegion() => multi?.call(cast()), }; @override diff --git a/lib/src/regions/recovered_region.dart b/lib/src/regions/recovered_region.dart index d8a7c9d9..697600ee 100644 --- a/lib/src/regions/recovered_region.dart +++ b/lib/src/regions/recovered_region.dart @@ -6,6 +6,8 @@ part of '../../flutter_map_tile_caching.dart'; /// A wrapper containing recovery & some downloadable region information, around /// a [DownloadableRegion] /// +/// Only [id] is used to compare equality. +/// /// See [RootRecovery] for information about the recovery system. class RecoveredRegion { /// Create a wrapper containing recovery information around a @@ -23,6 +25,8 @@ class RecoveredRegion { }); /// A unique ID created for every bulk download operation + /// + /// Only this is used to compare equality. final int id; /// The store name originally associated with this download @@ -52,6 +56,21 @@ class RecoveredRegion { /// The [BaseRegion] which was recovered final R region; + /// Cast [region] from [R] to [N] + /// + /// Throws if uncastable. + @optionalTypeArgs + RecoveredRegion cast() => RecoveredRegion( + region: region as N, + id: id, + minZoom: minZoom, + maxZoom: maxZoom, + start: start, + end: end, + storeName: storeName, + time: time, + ); + /// Convert this region into a [DownloadableRegion] DownloadableRegion toDownloadable( TileLayer options, { @@ -66,4 +85,11 @@ class RecoveredRegion { end: end, crs: crs, ); + + @override + bool operator ==(Object other) => + identical(this, other) || (other is RecoveredRegion && other.id == id); + + @override + int get hashCode => id; } diff --git a/lib/src/root/recovery.dart b/lib/src/root/recovery.dart index 21890cf6..dccf9636 100644 --- a/lib/src/root/recovery.dart +++ b/lib/src/root/recovery.dart @@ -47,6 +47,16 @@ class RootRecovery { /// can be ignored when fetching [recoverableRegions] final Set _downloadsOngoing; + /// {@macro fmtc.backend.watchRecovery} + Stream watch({ + bool triggerImmediately = false, + }) async* { + final stream = FMTCBackendAccess.internal.watchRecovery( + triggerImmediately: triggerImmediately, + ); + yield* stream; + } + /// List all recoverable regions, and whether each one has failed /// /// Result can be filtered to only include failed downloads using the diff --git a/lib/src/root/statistics.dart b/lib/src/root/statistics.dart index 786da3af..88ec61f0 100644 --- a/lib/src/root/statistics.dart +++ b/lib/src/root/statistics.dart @@ -22,14 +22,11 @@ class RootStats { Future get length => FMTCBackendAccess.internal.rootLength(); /// {@macro fmtc.backend.watchRecovery} + @Deprecated('This has been moved to `FMTCRoot.recovery` & renamed `.watch`') Stream watchRecovery({ bool triggerImmediately = false, - }) async* { - final stream = FMTCBackendAccess.internal.watchRecovery( - triggerImmediately: triggerImmediately, - ); - yield* stream; - } + }) => + FMTCRoot.recovery.watch(triggerImmediately: triggerImmediately); /// {@macro fmtc.backend.watchStores} ///