From 458a6c39781d4e35db80d826a87cffc3c8e76bda Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 9 Oct 2024 21:37:44 +0100 Subject: [PATCH] Improved example app --- .../components/greyscale_masker.dart | 14 +- .../download_progress_masker.dart | 48 ++++- .../src/screens/main/map_view/map_view.dart | 36 ++-- .../components/store_selector.dart | 101 +++++++++ .../config_options/config_options.dart | 40 +++- .../confirmation_panel.dart | 191 ++++++++++++++++++ .../download_configuration_view_side.dart | 70 +------ .../components/start_download_button.dart | 21 +- .../components/store_selector.dart | 12 +- .../confirm_cancellation_dialog.dart | 11 +- .../download/components/main_statistics.dart | 56 ++--- .../src/shared/components/url_selector.dart | 155 +++++++------- .../download_configuration_provider.dart | 9 +- lib/src/regions/downloadable_region.dart | 26 +-- 14 files changed, 542 insertions(+), 248 deletions(-) create mode 100644 example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/store_selector.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/download_configuration/components/confirmation_panel/confirmation_panel.dart diff --git a/example/lib/src/screens/main/map_view/components/download_progress/components/greyscale_masker.dart b/example/lib/src/screens/main/map_view/components/download_progress/components/greyscale_masker.dart index 69678117..1dd2ea07 100644 --- a/example/lib/src/screens/main/map_view/components/download_progress/components/greyscale_masker.dart +++ b/example/lib/src/screens/main/map_view/components/download_progress/components/greyscale_masker.dart @@ -1,12 +1,4 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart' hide Path; +part of '../download_progress_masker.dart'; class GreyscaleMasker extends SingleChildRenderObjectWidget { const GreyscaleMasker({ @@ -16,7 +8,7 @@ class GreyscaleMasker extends SingleChildRenderObjectWidget { required this.mapCamera, required this.minZoom, required this.maxZoom, - this.tileSize = 256, + required this.tileSize, }); final Stream tileCoordinatesStream; @@ -413,7 +405,7 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /*context.canvas.clipRect(Offset.zero & size); context.canvas.drawColor( Colors.green, - BlendMode.modulate, + BlendMode.hue, );*/ }, clipBehavior: Clip.hardEdge, diff --git a/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart b/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart index 8eabca1f..7019d91b 100644 --- a/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart +++ b/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart @@ -1,14 +1,29 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:math'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' hide Path; -import 'components/greyscale_masker.dart'; +part 'components/greyscale_masker.dart'; class DownloadProgressMasker extends StatefulWidget { const DownloadProgressMasker({ super.key, + required this.tileCoordinatesStream, + required this.minZoom, + required this.maxZoom, + this.tileSize = 256, required this.child, }); + final Stream? tileCoordinatesStream; + final int minZoom; + final int maxZoom; + final int tileSize; final TileLayer child; @override @@ -17,12 +32,27 @@ class DownloadProgressMasker extends StatefulWidget { class _DownloadProgressMaskerState extends State { @override - Widget build( - BuildContext - context) => /* GreyscaleMasker( - mapCamera: MapCamera.of(context), - tileMapping: _tileMapping, - child: widget.child, - );*/ - Placeholder(); + Widget build(BuildContext context) { + if (widget.tileCoordinatesStream case final tcs?) { + return RepaintBoundary( + child: GreyscaleMasker( + /*key: ObjectKey( + ( + widget.minZoom, + widget.maxZoom, + widget.tileCoordinatesStream, + widget.tileSize, + ), + ),*/ + mapCamera: MapCamera.of(context), + tileCoordinatesStream: tcs, + minZoom: widget.minZoom, + maxZoom: widget.maxZoom, + tileSize: widget.tileSize, + child: widget.child, + ), + ); + } + return widget.child; + } } 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 ae4a8d51..9b1dd27a 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -11,14 +10,13 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:http/io_client.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import 'package:stream_transform/stream_transform.dart'; import '../../../shared/misc/shared_preferences.dart'; import '../../../shared/misc/store_metadata_keys.dart'; import '../../../shared/state/general_provider.dart'; import '../../../shared/state/region_selection_provider.dart'; import 'components/debugging_tile_builder/debugging_tile_builder.dart'; -import 'components/download_progress/components/greyscale_masker.dart'; +import 'components/download_progress/download_progress_masker.dart'; import 'components/region_selection/crosshairs.dart'; import 'components/region_selection/custom_polygon_snapping_indicator.dart'; import 'components/region_selection/region_shape.dart'; @@ -67,7 +65,7 @@ class _MapViewState extends State with TickerProviderStateMixin { }, ).distinct(mapEquals); - final _testingCoordsList = [ + /*final _testingCoordsList = [ //TileCoordinates(2212, 1468, 12), //TileCoordinates(2212 * 2, 1468 * 2, 13), //TileCoordinates(2212 * 2 * 2, 1468 * 2 * 2, 14), @@ -122,7 +120,7 @@ class _MapViewState extends State with TickerProviderStateMixin { 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 2, 19, ), - ]; + ];*/ Stream? _testingDownloadTileCoordsStream; @@ -177,7 +175,8 @@ class _MapViewState extends State with TickerProviderStateMixin { ) .map( (event) => event.latestTileEvent.coordinates, - ), + ) + .asBroadcastStream(), ); return; } @@ -416,27 +415,16 @@ class _MapViewState extends State with TickerProviderStateMixin { mapController: _mapController.mapController, options: mapOptions, children: [ - if (_testingDownloadTileCoordsStream == null) - tileLayer - else - RepaintBoundary( - child: Builder( - builder: (context) => GreyscaleMasker( - key: const ValueKey(11), - mapCamera: MapCamera.of(context), - tileCoordinatesStream: - _testingDownloadTileCoordsStream!, - /*tileCoordinates: Stream.periodic( + DownloadProgressMasker( + tileCoordinatesStream: _testingDownloadTileCoordsStream, + /*tileCoordinates: Stream.periodic( const Duration(seconds: 5), _testingCoordsList.elementAtOrNull, ).whereNotNull(),*/ - minZoom: 11, - maxZoom: 16, - tileSize: 256, - child: tileLayer, - ), - ), - ), + minZoom: 11, + maxZoom: 16, + child: tileLayer, + ), PolygonLayer( polygons: [ Polygon( 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 new file mode 100644 index 00000000..4c02050a --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/store_selector.dart @@ -0,0 +1,101 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +import '../../../../../../../../shared/misc/store_metadata_keys.dart'; + +class StoreSelector extends StatefulWidget { + const StoreSelector({ + super.key, + this.storeName, + required this.onStoreNameSelected, + }); + + final String? storeName; + final void Function(String?) onStoreNameSelected; + + @override + State createState() => _StoreSelectorState(); +} + +class _StoreSelectorState extends State { + late final _storesToTemplatesStream = FMTCRoot.stats + .watchStores(triggerImmediately: true) + .asyncMap( + (_) async => Map.fromEntries( + await Future.wait( + (await FMTCRoot.stats.storesAvailable).map( + (s) async => MapEntry( + s.storeName, + await s.metadata.read.then( + (metadata) => metadata[StoreMetadataKeys.urlTemplate.key], + ), + ), + ), + ), + ), + ) + .distinct(mapEquals); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => SizedBox( + width: constraints.maxWidth, + child: StreamBuilder( + stream: _storesToTemplatesStream, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator.adaptive(), + SizedBox(width: 24), + Text('Loading stores...'), + ], + ); + } + + return DropdownMenu( + dropdownMenuEntries: _constructMenuEntries(snapshot), + onSelected: widget.onStoreNameSelected, + width: constraints.maxWidth, + leadingIcon: const Icon(Icons.inventory), + hintText: 'Select Store', + initialSelection: widget.storeName, + errorText: widget.storeName == null + ? 'Select a store to download tiles to' + : null, + inputDecorationTheme: const InputDecorationTheme( + filled: true, + helperMaxLines: 2, + ), + ); + }, + ), + ), + ); + + List> _constructMenuEntries( + AsyncSnapshot> snapshot, + ) => + snapshot.data!.entries + .whereNot((e) => e.value == null) + .map( + (e) => DropdownMenuEntry( + value: e.key, + label: e.key, + labelWidget: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(e.key), + Text( + Uri.tryParse(e.value!)?.host ?? e.value!, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + ), + ) + .toList(); +} 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 a5f72f1b..bca5385b 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 @@ -2,7 +2,7 @@ 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 'components/store_selector.dart'; class ConfigOptions extends StatefulWidget { const ConfigOptions({super.key}); @@ -14,6 +14,9 @@ class ConfigOptions extends StatefulWidget { class _ConfigOptionsState extends State { @override Widget build(BuildContext context) { + final storeName = context.select( + (p) => p.selectedStoreName, + ); final minZoom = context.select((p) => p.minZoom); final maxZoom = @@ -28,6 +31,13 @@ class _ConfigOptionsState extends State { return SingleChildScrollView( child: Column( children: [ + StoreSelector( + storeName: storeName, + onStoreNameSelected: (storeName) => context + .read() + .selectedStoreName = storeName, + ), + const Divider(height: 24), Row( children: [ const Tooltip(message: 'Zoom Levels', child: Icon(Icons.search)), @@ -46,7 +56,7 @@ class _ConfigOptionsState extends State { ), ], ), - const Divider(), + const Divider(height: 24), Row( children: [ const Tooltip( @@ -58,8 +68,9 @@ class _ConfigOptionsState extends State { child: Slider( value: parallelThreads.toDouble(), label: '$parallelThreads threads', + min: 1, max: 10, - divisions: 10, + divisions: 9, onChanged: (r) => context .read() .parallelThreads = r.toInt(), @@ -112,6 +123,29 @@ class _ConfigOptionsState extends State { ), ], ), + const Divider(height: 24), + Row( + children: [ + const Icon(Icons.skip_next), + const SizedBox(width: 4), + const Icon(Icons.file_copy), + const SizedBox(width: 12), + const Text('Skip Existing Tiles'), + const Spacer(), + Switch.adaptive(value: true, onChanged: (value) {}), + ], + ), + Row( + children: [ + const Icon(Icons.skip_next), + const SizedBox(width: 4), + const Icon(Icons.waves), + const SizedBox(width: 12), + const Text('Skip Sea Tiles'), + const Spacer(), + Switch.adaptive(value: true, onChanged: (value) {}), + ], + ), ], ), ); 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 new file mode 100644 index 00000000..4f4f7cd5 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/confirmation_panel/confirmation_panel.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../shared/state/download_configuration_provider.dart'; +import '../../../../../../../shared/state/region_selection_provider.dart'; + +class ConfirmationPanel extends StatefulWidget { + const ConfirmationPanel({super.key}); + + @override + State createState() => _ConfirmationPanelState(); +} + +class _ConfirmationPanelState extends State { + DownloadableRegion? _prevDownloadableRegion; + late Future _tileCount; + + void _updateTileCount() { + _tileCount = const FMTCStore('').download.check(_prevDownloadableRegion!); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final startTile = + context.select((p) => p.startTile); + final endTile = + context.select((p) => p.endTile); + final hasSelectedStoreName = + context.select( + (p) => p.selectedStoreName, + ) != + null; + + // Not suitable for download! + final downloadableRegion = MultiRegion( + context + .select>( + (p) => p.constructedRegions, + ) + .keys + .toList(growable: false), + ).toDownloadable( + minZoom: + context.select((p) => p.minZoom), + maxZoom: + context.select((p) => p.maxZoom), + start: startTile, + end: endTile, + options: TileLayer(), + ); + if (_prevDownloadableRegion == null || + downloadableRegion.originalRegion != + _prevDownloadableRegion!.originalRegion || + downloadableRegion.minZoom != _prevDownloadableRegion!.minZoom || + downloadableRegion.maxZoom != _prevDownloadableRegion!.maxZoom || + downloadableRegion.start != _prevDownloadableRegion!.start || + downloadableRegion.end != _prevDownloadableRegion!.end) { + _prevDownloadableRegion = downloadableRegion; + _updateTileCount(); + } + + return FutureBuilder( + future: _tileCount, + builder: (context, snapshot) => Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(width: 16), + Text( + '$startTile -', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: startTile == 1 + ? Theme.of(context) + .textTheme + .bodyLarge! + .color! + .withAlpha(255 ~/ 3) + : Colors.amber, + fontWeight: startTile == 1 ? null : FontWeight.bold, + ), + ), + const Spacer(), + if (snapshot.connectionState != ConnectionState.done) + const Padding( + padding: EdgeInsets.only(right: 4), + child: SizedBox.square( + dimension: 40, + child: Center( + child: SizedBox.square( + dimension: 28, + child: CircularProgressIndicator.adaptive(), + ), + ), + ), + ) + else + Text( + NumberFormat.decimalPatternDigits(decimalDigits: 0) + .format(snapshot.data), + style: Theme.of(context) + .textTheme + .headlineLarge! + .copyWith(fontWeight: FontWeight.bold), + ), + Text( + ' tiles', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + Text( + '- ${endTile ?? '∞'}', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: endTile == null + ? Theme.of(context) + .textTheme + .bodyLarge! + .color! + .withAlpha(255 ~/ 3) + : null, + fontWeight: startTile == 1 ? null : FontWeight.bold, + ), + ), + const SizedBox(width: 16), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.all(8), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.amber, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.warning_amber, size: 28), + ), + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.amber[200], + borderRadius: BorderRadius.circular(16), + ), + child: const Padding( + padding: EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "You must abide by your tile server's Terms of " + 'Service when bulk downloading.', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + 'Many servers will ' + 'forbid or heavily restrict this action, as it ' + 'places extra strain on resources. Be respectful, ' + 'and note that you use this functionality at your ' + 'own risk.', + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 46, + width: double.infinity, + child: FilledButton.icon( + onPressed: !hasSelectedStoreName ? null : () {}, + label: const Text('Start Download'), + icon: const Icon(Icons.download), + ), + ), + ], + ), + ); + } +} 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 08980f94..06e1cd3a 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 @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; -import '../region_selection/components/sub_regions_list/components/no_sub_regions.dart'; -import '../region_selection/components/sub_regions_list/sub_regions_list.dart'; +import '../../layouts/side/components/panel.dart'; import 'components/config_options/config_options.dart'; +import 'components/confirmation_panel/confirmation_panel.dart'; class DownloadConfigurationViewSide extends StatelessWidget { const DownloadConfigurationViewSide({super.key}); @@ -32,70 +32,14 @@ class DownloadConfigurationViewSide extends StatelessWidget { ), ), const SizedBox(height: 16), - Expanded( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.surface, - ), - width: double.infinity, - height: double.infinity, - child: Stack( - children: [ - const Padding( - padding: EdgeInsets.all(16), - child: ConfigOptions(), - ), - PositionedDirectional( - end: 8, - bottom: 8, - child: IgnorePointer( - ignoring: false, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - opacity: 1, - child: DecoratedBox( - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(99), - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: IntrinsicHeight( - child: Row( - children: [ - IconButton( - onPressed: () => context - .read() - .clearConstructedRegions(), - icon: const Icon(Icons.delete_forever), - ), - const SizedBox(width: 8), - SizedBox( - height: double.infinity, - child: FilledButton.icon( - onPressed: () => context - .read() - .isDownloadSetupPanelVisible = true, - label: const Text('Configure Download'), - icon: const Icon(Icons.tune), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ], - ), + const Expanded( + child: SideViewPanel( + child: SingleChildScrollView(child: ConfigOptions()), ), ), const SizedBox(height: 16), + const SideViewPanel(child: ConfirmationPanel()), + const SizedBox(height: 16), ], ); } diff --git a/example/lib/src/screens/old/configure_download/components/start_download_button.dart b/example/lib/src/screens/old/configure_download/components/start_download_button.dart index 28936fbb..847c49ce 100644 --- a/example/lib/src/screens/old/configure_download/components/start_download_button.dart +++ b/example/lib/src/screens/old/configure_download/components/start_download_button.dart @@ -21,8 +21,8 @@ class StartDownloadButton extends StatelessWidget { @override Widget build(BuildContext context) => - Selector( - selector: (context, provider) => provider.selectedStore, + Selector( + selector: (context, provider) => provider.selectedStoreName, builder: (context, selectedStore, child) { final enabled = selectedStore != null && maxTiles != null; @@ -45,9 +45,10 @@ class StartDownloadButton extends StatelessWidget { final configureDownloadProvider = context.read(); - if (!await configureDownloadProvider - .selectedStore!.manage.ready && - context.mounted) { + final selectedStore = + FMTCStore(configureDownloadProvider.selectedStoreName!); + + if (!await selectedStore.manage.ready && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Selected store no longer exists'), @@ -56,10 +57,8 @@ class StartDownloadButton extends StatelessWidget { return; } - final urlTemplate = (await configureDownloadProvider - .selectedStore! - .metadata - .read)[StoreMetadataKeys.urlTemplate.key]!; + final urlTemplate = (await selectedStore + .metadata.read)[StoreMetadataKeys.urlTemplate.key]!; if (!context.mounted) return; @@ -67,9 +66,7 @@ class StartDownloadButton extends StatelessWidget { Navigator.of(context).popAndPushNamed( DownloadPopup.route, arguments: ( - downloadProgress: configureDownloadProvider - .selectedStore!.download - .startForeground( + downloadProgress: selectedStore.download.startForeground( region: region.originalRegion.toDownloadable( minZoom: region.minZoom, maxZoom: region.maxZoom, diff --git a/example/lib/src/screens/old/configure_download/components/store_selector.dart b/example/lib/src/screens/old/configure_download/components/store_selector.dart index c7f1d070..559fc3a0 100644 --- a/example/lib/src/screens/old/configure_download/components/store_selector.dart +++ b/example/lib/src/screens/old/configure_download/components/store_selector.dart @@ -18,16 +18,16 @@ class _StoreSelectorState extends State { const Text('Store'), const Spacer(), IntrinsicWidth( - child: Selector( - selector: (context, provider) => provider.selectedStore, + child: Selector( + selector: (context, provider) => provider.selectedStoreName, builder: (context, selectedStore, _) => FutureBuilder>( future: FMTCRoot.stats.storesAvailable, builder: (context, snapshot) { final items = snapshot.data ?.map( - (e) => DropdownMenuItem( - value: e, + (e) => DropdownMenuItem( + value: e.storeName, child: Text(e.storeName), ), ) @@ -38,11 +38,11 @@ class _StoreSelectorState extends State { ? 'None Available' : 'None Selected'; - return DropdownButton( + return DropdownButton( items: items, onChanged: (store) => context .read() - .selectedStore = store, + .selectedStoreName = store, value: selectedStore, hint: Text(text), padding: const EdgeInsets.only(left: 12), diff --git a/example/lib/src/screens/old/download/components/confirm_cancellation_dialog.dart b/example/lib/src/screens/old/download/components/confirm_cancellation_dialog.dart index ccc41e35..9114acf5 100644 --- a/example/lib/src/screens/old/download/components/confirm_cancellation_dialog.dart +++ b/example/lib/src/screens/old/download/components/confirm_cancellation_dialog.dart @@ -1,4 +1,5 @@ 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_configuration_provider.dart'; @@ -30,11 +31,11 @@ class _ConfirmCancellationDialogState extends State { FilledButton( onPressed: () async { setState(() => isCancelling = true); - await context - .read() - .selectedStore! - .download - .cancel(); + await FMTCStore( + context + .read() + .selectedStoreName!, + ).download.cancel(); if (context.mounted) Navigator.of(context).pop(true); }, child: const Text('Cancel download'), diff --git a/example/lib/src/screens/old/download/components/main_statistics.dart b/example/lib/src/screens/old/download/components/main_statistics.dart index 078b44a1..b615db27 100644 --- a/example/lib/src/screens/old/download/components/main_statistics.dart +++ b/example/lib/src/screens/old/download/components/main_statistics.dart @@ -88,33 +88,37 @@ class _MainStatisticsState extends State { const SizedBox(height: 24), if (!(widget.download?.isComplete ?? false)) RepaintBoundary( - child: Selector( - selector: (context, provider) => provider.selectedStore, - builder: (context, selectedStore, _) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton.outlined( - onPressed: () async { - if (selectedStore.download.isPaused()) { - selectedStore.download.resume(); - } else { - await selectedStore.download.pause(); - } - setState(() {}); - }, - icon: Icon( - selectedStore!.download.isPaused() - ? Icons.play_arrow - : Icons.pause, + child: Selector( + selector: (context, provider) => provider.selectedStoreName, + builder: (context, selectedStoreName, _) { + final selectedStore = FMTCStore(selectedStoreName!); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton.outlined( + onPressed: () async { + if (selectedStore.download.isPaused()) { + selectedStore.download.resume(); + } else { + await selectedStore.download.pause(); + } + setState(() {}); + }, + icon: Icon( + selectedStore.download.isPaused() + ? Icons.play_arrow + : Icons.pause, + ), ), - ), - const SizedBox(width: 12), - IconButton.outlined( - onPressed: () => selectedStore.download.cancel(), - icon: const Icon(Icons.cancel), - ), - ], - ), + const SizedBox(width: 12), + IconButton.outlined( + onPressed: () => selectedStore.download.cancel(), + icon: const Icon(Icons.cancel), + ), + ], + ); + }, ), ), if (widget.download?.isComplete ?? false) diff --git a/example/lib/src/shared/components/url_selector.dart b/example/lib/src/shared/components/url_selector.dart index af8cb024..8ce01f39 100644 --- a/example/lib/src/shared/components/url_selector.dart +++ b/example/lib/src/shared/components/url_selector.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:async/async.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; @@ -32,55 +33,49 @@ class _URLSelectorState extends State { static const _defaultUrlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; - late final urlTextController = TextEditingControllerWithMatcherStylizer( + late final _urlTextController = TextEditingControllerWithMatcherStylizer( TileProvider.templatePlaceholderElement, const TextStyle(fontStyle: FontStyle.italic), initialValue: widget.initialValue, ); - final selectableEntriesManualRefreshStream = StreamController(); + final _selectableEntriesManualRefreshStream = StreamController(); - late final inUseUrlsStream = (StreamGroup() - ..add(FMTCRoot.stats.watchStores(triggerImmediately: true)) - ..add(selectableEntriesManualRefreshStream.stream)) - .stream - .asyncMap(_constructTemplatesToStoresStream); + late final _templatesToStoresStream = + (StreamGroup>>() + ..add( + _transformToTemplatesToStoresOnTrigger( + FMTCRoot.stats.watchStores(triggerImmediately: true), + ), + ) + ..add( + _transformToTemplatesToStoresOnTrigger( + _selectableEntriesManualRefreshStream.stream, + ), + )) + .stream; - Map> enableButtonEvaluatorMap = {}; - final enableAddUrlButton = ValueNotifier(false); + Map> _enableButtonEvaluatorMap = {}; + final _enableAddUrlButton = ValueNotifier(false); - late final dropdownMenuFocusNode = + late final _dropdownMenuFocusNode = widget.onFocus != null || widget.onUnfocus != null ? FocusNode() : null; @override void initState() { super.initState(); - urlTextController.addListener(_urlTextControllerListener); - dropdownMenuFocusNode?.addListener(_dropdownMenuFocusListener); + _urlTextController.addListener(_urlTextControllerListener); + _dropdownMenuFocusNode?.addListener(_dropdownMenuFocusListener); } @override void dispose() { - urlTextController.removeListener(_urlTextControllerListener); - dropdownMenuFocusNode?.removeListener(_dropdownMenuFocusListener); - selectableEntriesManualRefreshStream.close(); + _urlTextController.removeListener(_urlTextControllerListener); + _dropdownMenuFocusNode?.removeListener(_dropdownMenuFocusListener); + _selectableEntriesManualRefreshStream.close(); super.dispose(); } - void _dropdownMenuFocusListener() { - if (widget.onFocus != null && dropdownMenuFocusNode!.hasFocus) { - widget.onFocus!(); - } - if (widget.onUnfocus != null && !dropdownMenuFocusNode!.hasFocus) { - widget.onUnfocus!(); - } - } - - void _urlTextControllerListener() { - enableAddUrlButton.value = - !enableButtonEvaluatorMap.containsKey(urlTextController.text); - } - @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) => SizedBox( @@ -89,12 +84,12 @@ class _URLSelectorState extends State { initialData: const { _defaultUrlTemplate: ['(default)'], }, - stream: inUseUrlsStream, + stream: _templatesToStoresStream, builder: (context, snapshot) { // Bug in `DropdownMenu` means we must force the controller to // update to update the state of the entries - final oldValue = urlTextController.value; - urlTextController + final oldValue = _urlTextController.value; + _urlTextController ..value = TextEditingValue.empty ..value = oldValue; @@ -103,7 +98,7 @@ class _URLSelectorState extends State { children: [ Expanded( child: DropdownMenu( - controller: urlTextController, + controller: _urlTextController, width: constraints.maxWidth, requestFocusOnTap: true, leadingIcon: const Icon(Icons.link), @@ -119,13 +114,13 @@ class _URLSelectorState extends State { onSelected: _onSelected, helperText: 'Use standard placeholders & include protocol' '${widget.helperText != null ? '\n${widget.helperText}' : ''}', - focusNode: dropdownMenuFocusNode, + focusNode: _dropdownMenuFocusNode, ), ), Padding( padding: const EdgeInsets.only(top: 6, left: 8), child: ValueListenableBuilder( - valueListenable: enableAddUrlButton, + valueListenable: _enableAddUrlButton, builder: (context, enableAddUrlButton, _) => IconButton.filledTonal( onPressed: @@ -147,45 +142,14 @@ class _URLSelectorState extends State { SharedPrefsKeys.customNonStoreUrls.name, (sharedPrefs.getStringList(SharedPrefsKeys.customNonStoreUrls.name) ?? []) - ..add(urlTextController.text), + ..add(_urlTextController.text), ); - selectableEntriesManualRefreshStream.add(null); + _selectableEntriesManualRefreshStream.add(null); } - widget.onSelected!(v ?? urlTextController.text); - dropdownMenuFocusNode?.unfocus(); - } - - Future>> _constructTemplatesToStoresStream( - _, - ) async { - final storesAndTemplates = await Future.wait( - (await FMTCRoot.stats.storesAvailable).map( - (store) async => ( - storeName: store.storeName, - urlTemplate: await store.metadata.read - .then((metadata) => metadata[StoreMetadataKeys.urlTemplate.key]) - ), - ), - ) - ..add((storeName: '(default)', urlTemplate: _defaultUrlTemplate)) - ..addAll( - (sharedPrefs.getStringList(SharedPrefsKeys.customNonStoreUrls.name) ?? - []) - .map((url) => (storeName: '(custom)', urlTemplate: url)), - ); - - final templateToStores = >{}; - - for (final st in storesAndTemplates) { - if (st.urlTemplate == null) continue; - (templateToStores[st.urlTemplate!] ??= []).add(st.storeName); - } - - enableButtonEvaluatorMap = templateToStores; - - return templateToStores; + widget.onSelected!(v ?? _urlTextController.text); + _dropdownMenuFocusNode?.unfocus(); } List> _constructMenuEntries( @@ -218,7 +182,7 @@ class _URLSelectorState extends State { ..remove(e.key), ); - selectableEntriesManualRefreshStream.add(null); + _selectableEntriesManualRefreshStream.add(null); }, icon: const Icon(Icons.delete_outline), tooltip: 'Remove URL from non-store list', @@ -237,6 +201,55 @@ class _URLSelectorState extends State { enabled: false, ), ); + + Stream>> _transformToTemplatesToStoresOnTrigger( + Stream triggerStream, + ) => + triggerStream.asyncMap( + (e) async { + final storesAndTemplates = await Future.wait( + (await FMTCRoot.stats.storesAvailable).map( + (s) async => ( + storeName: s.storeName, + urlTemplate: await s.metadata.read.then( + (metadata) => metadata[StoreMetadataKeys.urlTemplate.key], + ) + ), + ), + ) + ..add((storeName: '(default)', urlTemplate: _defaultUrlTemplate)) + ..addAll( + (sharedPrefs.getStringList( + SharedPrefsKeys.customNonStoreUrls.name, + ) ?? + []) + .map((url) => (storeName: '(custom)', urlTemplate: url)), + ); + + final templateToStores = >{}; + + for (final st in storesAndTemplates) { + if (st.urlTemplate == null) continue; + (templateToStores[st.urlTemplate!] ??= []).add(st.storeName); + } + + return _enableButtonEvaluatorMap = templateToStores; + }, + ).distinct(mapEquals); + + void _dropdownMenuFocusListener() { + if (widget.onFocus != null && _dropdownMenuFocusNode!.hasFocus) { + widget.onFocus!(); + } + if (widget.onUnfocus != null && !_dropdownMenuFocusNode!.hasFocus) { + widget.onUnfocus!(); + } + } + + void _urlTextControllerListener() { + _enableAddUrlButton.value = + !_enableButtonEvaluatorMap.containsKey(_urlTextController.text); + } } // Inspired by https://stackoverflow.com/a/59773962/11846040 diff --git a/example/lib/src/shared/state/download_configuration_provider.dart b/example/lib/src/shared/state/download_configuration_provider.dart index 6430b50e..343e130d 100644 --- a/example/lib/src/shared/state/download_configuration_provider.dart +++ b/example/lib/src/shared/state/download_configuration_provider.dart @@ -1,5 +1,4 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; class DownloadConfigurationProvider extends ChangeNotifier { static const defaultValues = ( @@ -77,10 +76,10 @@ class DownloadConfigurationProvider extends ChangeNotifier { notifyListeners(); } - FMTCStore? _selectedStore; - FMTCStore? get selectedStore => _selectedStore; - set selectedStore(FMTCStore? newStore) { - _selectedStore = newStore; + String? _selectedStoreName; + String? get selectedStoreName => _selectedStoreName; + set selectedStoreName(String? newStoreName) { + _selectedStoreName = newStoreName; notifyListeners(); } } diff --git a/lib/src/regions/downloadable_region.dart b/lib/src/regions/downloadable_region.dart index 3e00db0f..5d9aee9f 100644 --- a/lib/src/regions/downloadable_region.dart +++ b/lib/src/regions/downloadable_region.dart @@ -29,9 +29,6 @@ class DownloadableRegion { } /// A copy of the [BaseRegion] used to form this object - /// - /// To make decisions based on the type of this region, prefer [when] over - /// switching on [R] manually. final R originalRegion; /// The minimum zoom level to fetch tiles for @@ -124,16 +121,19 @@ class DownloadableRegion { }; @override - bool operator ==(Object other) => - identical(this, other) || - (other is DownloadableRegion && - other.originalRegion == originalRegion && - other.minZoom == minZoom && - other.maxZoom == maxZoom && - other.options == options && - other.start == start && - other.end == end && - other.crs == crs); + bool operator ==(Object other) { + final e = identical(this, other) || + (other is DownloadableRegion && + other.originalRegion == originalRegion && + other.minZoom == minZoom && + other.maxZoom == maxZoom && + other.options == options && + other.start == start && + other.end == end && + other.crs == crs); + print((other as DownloadableRegion).options == options); + return e; + } @override int get hashCode => Object.hashAllUnordered([