From 15a82c5814df52a996a716c3501ab91d914deab3 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 24 May 2024 15:53:45 +0100 Subject: [PATCH 01/97] Added support for using multiple stores simultaneously in the `FMTCTileProvider` Replaced `FMTCTileProviderSettings.maxStoreLength` with `maxLength` on each store individually Exposed direct constructor for `FMTCTileProvider` Refactored and exposed tile provider logic into seperate `getBytes` method Added more `CacheBehavior` options Added toggle for hit/miss stat recording Added tests Improved documentation --- .../main/pages/map/components/map_view.dart | 4 +- lib/flutter_map_tile_caching.dart | 9 +- .../impls/objectbox/backend/backend.dart | 4 +- .../impls/objectbox/backend/internal.dart | 77 ++++-- .../backend/internal_workers/shared.dart | 51 ++-- .../internal_workers/standard/cmd_type.dart | 4 +- .../internal_workers/standard/worker.dart | 199 ++++++++++---- .../backend/internal_workers/thread_safe.dart | 2 +- .../models/generated/objectbox-model.json | 7 +- .../models/generated/objectbox.g.dart | 17 +- .../impls/objectbox/models/src/store.dart | 17 +- .../backend/interfaces/backend/internal.dart | 74 ++++-- lib/src/backend/interfaces/models.dart | 3 +- lib/src/bulk_download/manager.dart | 2 +- lib/src/providers/image_provider.dart | 250 ++++++++++-------- lib/src/providers/tile_provider.dart | 80 +++++- lib/src/providers/tile_provider_settings.dart | 87 +++--- lib/src/store/manage.dart | 18 +- lib/src/store/statistics.dart | 5 + lib/src/store/store.dart | 9 +- test/general_test.dart | 153 ++++++++++- 21 files changed, 776 insertions(+), 296 deletions(-) diff --git a/example/lib/screens/main/pages/map/components/map_view.dart b/example/lib/screens/main/pages/map/components/map_view.dart index fb7b5628..af3e2d1f 100644 --- a/example/lib/screens/main/pages/map/components/map_view.dart +++ b/example/lib/screens/main/pages/map/components/map_view.dart @@ -73,8 +73,8 @@ class MapView extends StatelessWidget { metadata.data!['validDuration']!, ), ), - maxStoreLength: - int.parse(metadata.data!['maxLength']!), + /*maxStoreLength: + int.parse(metadata.data!['maxLength']!),*/ ), ) : NetworkTileProvider(), diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index a731dddb..23640404 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -15,7 +15,8 @@ import 'dart:collection'; import 'dart:io'; import 'dart:isolate'; import 'dart:math' as math; -import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui'; import 'package:async/async.dart'; import 'package:collection/collection.dart'; @@ -36,7 +37,6 @@ import 'src/bulk_download/tile_loops/shared.dart'; import 'src/misc/int_extremes.dart'; import 'src/misc/obscure_query_params.dart'; import 'src/providers/browsing_errors.dart'; -import 'src/providers/image_provider.dart'; export 'src/backend/export_external.dart'; export 'src/providers/browsing_errors.dart'; @@ -46,6 +46,7 @@ part 'src/bulk_download/manager.dart'; part 'src/bulk_download/thread.dart'; part 'src/bulk_download/tile_event.dart'; part 'src/misc/deprecations.dart'; +part 'src/providers/image_provider.dart'; part 'src/providers/tile_provider.dart'; part 'src/providers/tile_provider_settings.dart'; part 'src/regions/base_region.dart'; @@ -55,12 +56,12 @@ part 'src/regions/downloadable_region.dart'; part 'src/regions/line.dart'; part 'src/regions/recovered_region.dart'; part 'src/regions/rectangle.dart'; -part 'src/root/root.dart'; part 'src/root/external.dart'; part 'src/root/recovery.dart'; +part 'src/root/root.dart'; part 'src/root/statistics.dart'; -part 'src/store/store.dart'; part 'src/store/download.dart'; part 'src/store/manage.dart'; part 'src/store/metadata.dart'; part 'src/store/statistics.dart'; +part 'src/store/store.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/backend.dart b/lib/src/backend/impls/objectbox/backend/backend.dart index fc70cc64..2d02d452 100644 --- a/lib/src/backend/impls/objectbox/backend/backend.dart +++ b/lib/src/backend/impls/objectbox/backend/backend.dart @@ -2,6 +2,7 @@ // A full license can be found at .\LICENSE import 'dart:async'; +import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; @@ -38,7 +39,8 @@ final class FMTCObjectBoxBackend implements FMTCBackend { /// --- /// /// [maxDatabaseSize] is the maximum size the database file can grow - /// to, in KB. Exceeding it throws [DbFullException]. Defaults to 10 GB. + /// to, in KB. Exceeding it throws [DbFullException] on write operations. + /// Defaults to 10 GB (10000000 KB). /// /// [macosApplicationGroup] should be set when creating a sandboxed macOS app, /// specify the application group (of less than 20 chars). See diff --git a/lib/src/backend/impls/objectbox/backend/internal.dart b/lib/src/backend/impls/objectbox/backend/internal.dart index 154e928b..c6775272 100644 --- a/lib/src/backend/impls/objectbox/backend/internal.dart +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -34,8 +34,8 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { // `removeOldestTilesAboveLimit` tracking & debouncing Timer? _rotalDebouncer; - String? _rotalStore; - Completer? _rotalResultCompleter; + int? _rotalStoresHash; + Completer>? _rotalResultCompleter; // Define communicators @@ -268,7 +268,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { _workerResStreamed.clear(); _rotalDebouncer?.cancel(); _rotalDebouncer = null; - _rotalStore = null; + _rotalStoresHash = null; _rotalResultCompleter?.completeError(RootUnavailable()); _rotalResultCompleter = null; @@ -294,6 +294,25 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { Future> listStores() async => (await _sendCmdOneShot(type: _CmdType.listStores))!['stores']; + @override + Future storeGetMaxLength({ + required String storeName, + }) async => + (await _sendCmdOneShot( + type: _CmdType.storeGetMaxLength, + args: {'storeName': storeName}, + ))!['maxLength']; + + @override + Future storeSetMaxLength({ + required String storeName, + required int? newMaxLength, + }) => + _sendCmdOneShot( + type: _CmdType.storeSetMaxLength, + args: {'storeName': storeName, 'newMaxLength': newMaxLength}, + ); + @override Future storeExists({ required String storeName, @@ -306,10 +325,11 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { @override Future createStore({ required String storeName, + required int? maxLength, }) => _sendCmdOneShot( type: _CmdType.createStore, - args: {'storeName': storeName}, + args: {'storeName': storeName, 'maxLength': maxLength}, ); @override @@ -353,25 +373,41 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ))!['stats']; @override - Future tileExistsInStore({ - required String storeName, + Future tileExists({ required String url, + List? storeNames, }) async => (await _sendCmdOneShot( - type: _CmdType.tileExistsInStore, - args: {'storeName': storeName, 'url': url}, + type: _CmdType.tileExists, + args: {'url': url, 'storeNames': storeNames}, ))!['exists']; @override Future readTile({ required String url, - String? storeName, + List? storeNames, }) async => (await _sendCmdOneShot( type: _CmdType.readTile, - args: {'url': url, 'storeName': storeName}, + args: {'url': url, 'storeNames': storeNames}, ))!['tile']; + @override + Future<({BackendTile? tile, List storeNames})> + readTileWithStoreNames({ + required String url, + List? storeNames, + }) async { + final res = (await _sendCmdOneShot( + type: _CmdType.readTile, + args: {'url': url, 'storeNames': storeNames}, + ))!; + return ( + tile: res['tile'] as BackendTile?, + storeNames: res['stores'] as List, + ); + } + @override Future readLatestTile({ required String storeName, @@ -383,13 +419,13 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { @override Future writeTile({ - required String storeName, required String url, required Uint8List bytes, + required List storeNames, }) => _sendCmdOneShot( type: _CmdType.writeTile, - args: {'storeName': storeName, 'url': url, 'bytes': bytes}, + args: {'storeNames': storeNames, 'url': url, 'bytes': bytes}, ); @override @@ -404,35 +440,34 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { @override Future registerHitOrMiss({ - required String storeName, + required List? storeNames, required bool hit, }) => _sendCmdOneShot( type: _CmdType.registerHitOrMiss, - args: {'storeName': storeName, 'hit': hit}, + args: {'storeNames': storeNames, 'hit': hit}, ); @override - Future removeOldestTilesAboveLimit({ - required String storeName, - required int tilesLimit, + Future> removeOldestTilesAboveLimit({ + required List storeNames, }) async { // By sharing a single completer, all invocations of this method during the // debounce period will return the same result at the same time if (_rotalResultCompleter?.isCompleted ?? true) { - _rotalResultCompleter = Completer(); + _rotalResultCompleter = Completer>(); } void sendCmdAndComplete() => _rotalResultCompleter!.complete( _sendCmdOneShot( type: _CmdType.removeOldestTilesAboveLimit, - args: {'storeName': storeName, 'tilesLimit': tilesLimit}, + args: {'storeNames': storeNames}, ).then((v) => v!['numOrphans']), ); // If the store has changed, failing to reset the batch/queue will mean // tiles are removed from the wrong store - if (_rotalStore != storeName) { - _rotalStore = storeName; + if (_rotalStoresHash != storeNames.hashCode) { + _rotalStoresHash = storeNames.hashCode; if (_rotalDebouncer?.isActive ?? false) { _rotalDebouncer!.cancel(); sendCmdAndComplete(); diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart index 926d7484..275f8c36 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart @@ -5,34 +5,35 @@ part of '../backend.dart'; void _sharedWriteSingleTile({ required Store root, - required String storeName, + required List storeNames, required String url, required Uint8List bytes, }) { final tiles = root.box(); - final stores = root.box(); + final storesBox = root.box(); final rootBox = root.box(); final tilesQuery = tiles.query(ObjectBoxTile_.url.equals(url)).build(); final storeQuery = - stores.query(ObjectBoxStore_.name.equals(storeName)).build(); + storesBox.query(ObjectBoxStore_.name.oneOf(storeNames)).build(); final storesToUpdate = {}; - // If tile exists in this store, just update size, otherwise - // length and size - // Also update size of all related stores - bool didContainAlready = false; root.runInTransaction( TxMode.write, () { final existingTile = tilesQuery.findUnique(); - final store = storeQuery.findUnique() ?? - (throw StoreNotExists(storeName: storeName)); + final stores = storeQuery.find(); // Assumed not empty if (existingTile != null) { + // If tile exists in this store, just update size, otherwise + // length and size + // Also update size of all related stores + final didContainAlready = {}; + for (final relatedStore in existingTile.stores) { - if (relatedStore.name == storeName) didContainAlready = true; + didContainAlready + .addAll(storeNames.where((s) => s == relatedStore.name)); storesToUpdate[relatedStore.name] = (storesToUpdate[relatedStore.name] ?? relatedStore) @@ -45,6 +46,17 @@ void _sharedWriteSingleTile({ ..size += -existingTile.bytes.lengthInBytes + bytes.lengthInBytes, mode: PutMode.update, ); + + storesToUpdate.addEntries( + stores.whereNot((s) => didContainAlready.contains(s.name)).map( + (s) => MapEntry( + s.name, + s + ..length += 1 + ..size += bytes.lengthInBytes, + ), + ), + ); } else { rootBox.put( rootBox.get(1)! @@ -52,12 +64,17 @@ void _sharedWriteSingleTile({ ..size += bytes.lengthInBytes, mode: PutMode.update, ); - } - if (!didContainAlready || existingTile == null) { - storesToUpdate[storeName] = store - ..length += 1 - ..size += bytes.lengthInBytes; + storesToUpdate.addEntries( + stores.map( + (s) => MapEntry( + s.name, + s + ..length += 1 + ..size += bytes.lengthInBytes, + ), + ), + ); } tiles.put( @@ -65,9 +82,9 @@ void _sharedWriteSingleTile({ url: url, lastModified: DateTime.timestamp(), bytes: bytes, - )..stores.addAll({store, ...?existingTile?.stores}), + )..stores.addAll({...stores, ...?existingTile?.stores}), ); - stores.putMany(storesToUpdate.values.toList(), mode: PutMode.update); + storesBox.putMany(storesToUpdate.values.toList(), mode: PutMode.update); }, ); diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart index a469cd2f..f171f0f8 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart @@ -10,13 +10,15 @@ enum _CmdType { rootSize, rootLength, listStores, + storeGetMaxLength, + storeSetMaxLength, storeExists, createStore, resetStore, renameStore, deleteStore, getStoreStats, - tileExistsInStore, + tileExists, readTile, readLatestTile, writeTile, diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index 37f9e17c..10e6acff 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -91,7 +91,6 @@ Future _worker( bool hadTilesToUpdate = false; int rootDeltaSize = 0; final tilesToRemove = []; - //final tileRelationsToUpdate = >[]; final storesToUpdate = {}; final queriedStores = storesQuery.property(ObjectBoxStore_.name).find(); @@ -262,13 +261,54 @@ Future _worker( ); query.close(); + case _CmdType.storeGetMaxLength: + final storeName = cmd.args['storeName']! as String; + + final query = root + .box() + .query(ObjectBoxStore_.name.equals(storeName)) + .build(); + + sendRes( + id: cmd.id, + data: { + 'maxLength': (query.findUnique() ?? + (throw StoreNotExists(storeName: storeName))) + .maxLength, + }, + ); + + query.close(); + case _CmdType.storeSetMaxLength: + final storeName = cmd.args['storeName']! as String; + final newMaxLength = cmd.args['newMaxLength'] as int?; + + final stores = root.box(); + + final query = + stores.query(ObjectBoxStore_.name.equals(storeName)).build(); + + root.runInTransaction( + TxMode.write, + () { + final store = query.findUnique() ?? + (throw StoreNotExists(storeName: storeName)); + query.close(); + + stores.put(store..maxLength = newMaxLength, mode: PutMode.update); + }, + ); + + sendRes(id: cmd.id); case _CmdType.createStore: final storeName = cmd.args['storeName']! as String; + final maxLength = cmd.args['maxLength'] as int?; try { root.box().put( ObjectBoxStore( name: storeName, + maxLength: maxLength, length: 0, size: 0, hits: 0, @@ -360,15 +400,19 @@ Future _worker( storesQuery.close(); tilesQuery.close(); }); - case _CmdType.tileExistsInStore: - final storeName = cmd.args['storeName']! as String; + case _CmdType.tileExists: final url = cmd.args['url']! as String; + final storeNames = cmd.args['storeNames']! as List?; - final query = - (root.box().query(ObjectBoxTile_.url.equals(url)) + final stores = root.box(); + + final queryPart = stores.query(ObjectBoxTile_.url.equals(url)); + final query = storeNames == null + ? queryPart.build() + : (queryPart ..linkMany( ObjectBoxTile_.stores, - ObjectBoxStore_.name.equals(storeName), + ObjectBoxStore_.name.oneOf(storeNames), )) .build(); @@ -377,23 +421,41 @@ Future _worker( query.close(); case _CmdType.readTile: final url = cmd.args['url']! as String; - final storeName = cmd.args['storeName'] as String?; + final storeNames = cmd.args['storeNames'] as List?; final stores = root.box(); final queryPart = stores.query(ObjectBoxTile_.url.equals(url)); - final query = storeName == null + final query = storeNames == null ? queryPart.build() : (queryPart ..linkMany( ObjectBoxTile_.stores, - ObjectBoxStore_.name.equals(storeName), + ObjectBoxStore_.name.oneOf(storeNames), )) .build(); - sendRes(id: cmd.id, data: {'tile': query.findUnique()}); - + final tile = query.findUnique(); query.close(); + + if (tile == null) { + sendRes(id: cmd.id, data: {'tile': null, 'stores': []}); + } else { + final tileStores = tile.stores.map((s) => s.name); + + sendRes( + id: cmd.id, + data: { + 'tile': tile, + 'stores': storeNames == null + ? tileStores.toList(growable: false) + : SplayTreeSet.from(tileStores) + .intersection(SplayTreeSet.from(storeNames)) + .toList(growable: false), + }, + ); + } + case _CmdType.readLatestTile: final storeName = cmd.args['storeName']! as String; @@ -411,13 +473,13 @@ Future _worker( query.close(); case _CmdType.writeTile: - final storeName = cmd.args['storeName']! as String; + final storeNames = cmd.args['storeNames']! as List; final url = cmd.args['url']! as String; final bytes = cmd.args['bytes']! as Uint8List; _sharedWriteSingleTile( root: root, - storeName: storeName, + storeNames: storeNames, url: url, bytes: bytes, ); @@ -447,33 +509,46 @@ Future _worker( tilesQuery.close(); }); case _CmdType.registerHitOrMiss: - final storeName = cmd.args['storeName']! as String; + final storeNames = cmd.args['storeNames'] as List?; final hit = cmd.args['hit']! as bool; - final stores = root.box(); + final storesBox = root.box(); + final specifiedStores = storeNames?.isNotEmpty ?? false; - final query = - stores.query(ObjectBoxStore_.name.equals(storeName)).build(); + late final Query query; + if (specifiedStores) { + query = + storesBox.query(ObjectBoxStore_.name.oneOf(storeNames!)).build(); + } root.runInTransaction( TxMode.write, () { - final store = query.findUnique() ?? - (throw StoreNotExists(storeName: storeName)); - query.close(); + final stores = specifiedStores ? query.find() : storesBox.getAll(); + if (specifiedStores) { + if (stores.length != storeNames!.length) { + return StoreNotExists( + storeName: + storeNames.toSet().difference(stores.toSet()).join('; '), + ); + } - stores.put( - store - ..hits += hit ? 1 : 0 - ..misses += hit ? 0 : 1, - ); + query.close(); + } + + for (final store in stores) { + storesBox.put( + store + ..hits += hit ? 1 : 0 + ..misses += hit ? 0 : 1, + ); + } }, ); sendRes(id: cmd.id); case _CmdType.removeOldestTilesAboveLimit: - final storeName = cmd.args['storeName']! as String; - final tilesLimit = cmd.args['tilesLimit']! as int; + final storeNames = cmd.args['storeNames']! as List; final tilesQuery = (root .box() @@ -481,39 +556,60 @@ Future _worker( .order(ObjectBoxTile_.lastModified) ..linkMany( ObjectBoxTile_.stores, - ObjectBoxStore_.name.equals(storeName), + ObjectBoxStore_.name.equals(''), )) .build(); final storeQuery = root .box() - .query(ObjectBoxStore_.name.equals(storeName)) + .query( + ObjectBoxStore_.name + .equals('') + .and(ObjectBoxStore_.maxLength.notNull()), + ) .build(); - final store = storeQuery.findUnique() ?? - (throw StoreNotExists(storeName: storeName)); + final numOrphans = Map.fromIterables( + storeNames, + List.filled(storeNames.length, null), + ); - final numToRemove = store.length - tilesLimit; + Future.wait( + List.generate( + storeNames.length, + (i) async { + final storeName = storeNames[i]; - if (numToRemove <= 0) { - sendRes(id: cmd.id, data: {'numOrphans': 0}); + tilesQuery.param(ObjectBoxStore_.name).value = storeName; + storeQuery.param(ObjectBoxStore_.name).value = storeName; - storeQuery.close(); - tilesQuery.close(); - } else { - tilesQuery.limit = numToRemove; + final store = storeQuery.findUnique(); + if (store == null) return; - deleteTiles(storesQuery: storeQuery, tilesQuery: tilesQuery) - .then((orphans) { - sendRes( - id: cmd.id, - data: {'numOrphans': orphans}, - ); + final numToRemove = store.length - store.maxLength!; - storeQuery.close(); - tilesQuery.close(); - }); - } + if (numToRemove <= 0) { + numOrphans[storeName] = 0; + return; + } + + tilesQuery.limit = numToRemove; + + final orphans = await deleteTiles( + storesQuery: storeQuery, + tilesQuery: tilesQuery, + ); + numOrphans[storeName] = orphans; + return; + }, + growable: false, + ), + ).then((_) { + sendRes(id: cmd.id, data: {'numOrphans': numOrphans}); + + storeQuery.close(); + tilesQuery.close(); + }); case _CmdType.removeTilesOlderThan: final storeName = cmd.args['storeName']! as String; final expiry = cmd.args['expiry']! as DateTime; @@ -793,6 +889,7 @@ Future _worker( storesObjectsForRelations[exportingStore.name] = ObjectBoxStore( name: exportingStore.name, + maxLength: exportingStore.maxLength, length: exportingStore.length, size: exportingStore.size, hits: exportingStore.hits, @@ -942,6 +1039,7 @@ Future _worker( root.box().put( ObjectBoxStore( name: name, + maxLength: importingStore.maxLength, length: importingStore.length, size: importingStore.size, hits: 0, @@ -967,6 +1065,7 @@ Future _worker( root.box().put( ObjectBoxStore( name: name, + maxLength: importingStore.maxLength, length: importingStore.length, size: importingStore.size, hits: 0, @@ -986,6 +1085,7 @@ Future _worker( root.box().put( ObjectBoxStore( name: newName, + maxLength: importingStore.maxLength, length: importingStore.length, size: importingStore.size, hits: 0, @@ -1011,6 +1111,7 @@ Future _worker( root.box().put( ObjectBoxStore( name: name, + maxLength: importingStore.maxLength, length: 0, // Will be set when writing tiles size: 0, // Will be set when writing tiles hits: 0, @@ -1024,6 +1125,7 @@ Future _worker( if (strategy == ImportConflictStrategy.merge) { root.box().put( existingStore + ..maxLength = importingStore.maxLength ..metadataJson = jsonEncode( (jsonDecode(existingStore.metadataJson) as Map) @@ -1128,6 +1230,7 @@ Future _worker( importingStores.length, (i) => ObjectBoxStore( name: importingStores[i].name, + maxLength: importingStores[i].maxLength, length: importingStores[i].length, size: importingStores[i].size, hits: importingStores[i].hits, diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart index 25f20b0f..7c5fb480 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart @@ -62,7 +62,7 @@ class _ObjectBoxBackendThreadSafeImpl implements FMTCBackendInternalThreadSafe { }) => _sharedWriteSingleTile( root: expectInitialisedRoot, - storeName: storeName, + storeNames: [storeName], url: url, bytes: bytes, ); diff --git a/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json b/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json index d8e27ba4..c1bf3d7a 100644 --- a/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json +++ b/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json @@ -121,7 +121,7 @@ }, { "id": "2:632249766926720928", - "lastPropertyId": "7:7028109958959828879", + "lastPropertyId": "8:3489822621946254204", "name": "ObjectBoxStore", "properties": [ { @@ -161,6 +161,11 @@ "id": "7:7028109958959828879", "name": "metadataJson", "type": 9 + }, + { + "id": "8:3489822621946254204", + "name": "maxLength", + "type": 6 } ], "relations": [] diff --git a/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart b/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart index 34e04171..52e0fa96 100644 --- a/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart +++ b/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart @@ -141,7 +141,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(2, 632249766926720928), name: 'ObjectBoxStore', - lastPropertyId: const obx_int.IdUid(7, 7028109958959828879), + lastPropertyId: const obx_int.IdUid(8, 3489822621946254204), flags: 0, properties: [ obx_int.ModelProperty( @@ -179,6 +179,11 @@ final _entities = [ id: const obx_int.IdUid(7, 7028109958959828879), name: 'metadataJson', type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 3489822621946254204), + name: 'maxLength', + type: 6, flags: 0) ], relations: [], @@ -429,7 +434,7 @@ obx_int.ModelDefinition getObjectBoxModel() { objectToFB: (ObjectBoxStore object, fb.Builder fbb) { final nameOffset = fbb.writeString(object.name); final metadataJsonOffset = fbb.writeString(object.metadataJson); - fbb.startTable(8); + fbb.startTable(9); fbb.addInt64(0, object.id); fbb.addOffset(1, nameOffset); fbb.addInt64(2, object.length); @@ -437,6 +442,7 @@ obx_int.ModelDefinition getObjectBoxModel() { fbb.addInt64(4, object.hits); fbb.addInt64(5, object.misses); fbb.addOffset(6, metadataJsonOffset); + fbb.addInt64(7, object.maxLength); fbb.finish(fbb.endTable()); return object.id; }, @@ -445,6 +451,8 @@ obx_int.ModelDefinition getObjectBoxModel() { final rootOffset = buffer.derefObject(0); final nameParam = const fb.StringReader(asciiOptimization: true) .vTableGet(buffer, rootOffset, 6, ''); + final maxLengthParam = + const fb.Int64Reader().vTableGetNullable(buffer, rootOffset, 18); final lengthParam = const fb.Int64Reader().vTableGet(buffer, rootOffset, 8, 0); final sizeParam = @@ -458,6 +466,7 @@ obx_int.ModelDefinition getObjectBoxModel() { .vTableGet(buffer, rootOffset, 16, ''); final object = ObjectBoxStore( name: nameParam, + maxLength: maxLengthParam, length: lengthParam, size: sizeParam, hits: hitsParam, @@ -658,6 +667,10 @@ class ObjectBoxStore_ { /// see [ObjectBoxStore.metadataJson] static final metadataJson = obx.QueryStringProperty(_entities[1].properties[6]); + + /// see [ObjectBoxStore.maxLength] + static final maxLength = + obx.QueryIntegerProperty(_entities[1].properties[7]); } /// [ObjectBoxTile] entity fields to define ObjectBox queries. diff --git a/lib/src/backend/impls/objectbox/models/src/store.dart b/lib/src/backend/impls/objectbox/models/src/store.dart index 690f6472..5ac059cb 100644 --- a/lib/src/backend/impls/objectbox/models/src/store.dart +++ b/lib/src/backend/impls/objectbox/models/src/store.dart @@ -15,6 +15,7 @@ class ObjectBoxStore { /// referenced by unique name, in ObjectBox ObjectBoxStore({ required this.name, + required this.maxLength, required this.length, required this.size, required this.hits, @@ -38,6 +39,12 @@ class ObjectBoxStore { @Backlink('stores') final tiles = ToMany(); + /// Maximum number of tiles allowable in this store + /// + /// This is enforced automatically when browse caching, but not when bulk + /// downloading. + int? maxLength; + /// Number of tiles int length; @@ -54,14 +61,4 @@ class ObjectBoxStore { /// /// Only supports string-string key-value pairs. String metadataJson; - - /*@override - bool operator ==(Object other) => - identical(this, other) || (other is ObjectBoxStore && name == other.name); - - @override - int get hashCode => name.hashCode; - - @override - String toString() => 'ObjectBoxStore(name: $name)';*/ } diff --git a/lib/src/backend/interfaces/backend/internal.dart b/lib/src/backend/interfaces/backend/internal.dart index 2935697e..c0e3a067 100644 --- a/lib/src/backend/interfaces/backend/internal.dart +++ b/lib/src/backend/interfaces/backend/internal.dart @@ -60,6 +60,31 @@ abstract interface class FMTCBackendInternal /// {@endtemplate} Future> listStores(); + /// {@template fmtc.backend.storeGetMaxLength} + /// Retrieve the maximum allowable number of tiles within the specified store + /// + /// This limit is enforced automatically when browse caching, but not when + /// bulk downloading. + /// + /// `null` means there is no configured limit. + /// {@endtemplate} + Future storeGetMaxLength({ + required String storeName, + }); + + /// {@template fmtc.backend.storeSetMaxLength} + /// Set the maximum allowable number of tiles within the specified store + /// + /// This limit is enforced automatically when browse caching, but not when + /// bulk downloading. + /// + /// Set `null` to disable the limit. + /// {@endtemplate} + Future storeSetMaxLength({ + required String storeName, + required int? newMaxLength, + }); + /// {@template fmtc.backend.storeExists} /// Check whether the specified store currently exists /// {@endtemplate} @@ -70,10 +95,15 @@ abstract interface class FMTCBackendInternal /// {@template fmtc.backend.createStore} /// Create a new store with the specified name /// + /// If set, [maxLength] will be the maximum allowed number of tiles in the + /// store. This limit is enforced automatically when browse caching, but not + /// when bulk downloading. Defaults to `null`: unlimited. + /// /// Does nothing if the store already exists. /// {@endtemplate} Future createStore({ required String storeName, + required int? maxLength, }); /// {@template fmtc.backend.deleteStore} @@ -125,19 +155,28 @@ abstract interface class FMTCBackendInternal required String storeName, }); - /// Check whether the specified tile exists in the specified store - Future tileExistsInStore({ - required String storeName, + /// Check whether the specified tile exists in any of the specified stores (or + /// any store is [storeNames] is `null`) + Future tileExists({ required String url, + List? storeNames, }); /// Retrieve a raw tile by the specified URL /// - /// If [storeName] is specified, the tile will be limited to the specified - /// store - if it exists in another store, it will not be returned. + /// If [storeNames] is specified, the tile will be limited to the specified + /// stores - if it exists in another store, it will not be returned. Future readTile({ required String url, - String? storeName, + List? storeNames, + }); + + /// Same as [readTile], but also returns the list of store names which this + /// tile belongs to and were present in [storeNames] (if specified) + Future<({BackendTile? tile, List storeNames})> + readTileWithStoreNames({ + required String url, + List? storeNames, }); /// {@template fmtc.backend.readLatestTile} @@ -151,9 +190,9 @@ abstract interface class FMTCBackendInternal /// Create or update a tile (given a [url] and its [bytes]) in the specified /// store Future writeTile({ - required String storeName, required String url, required Uint8List bytes, + required List storeNames, }); /// Remove the tile from the specified store, deleting it if was orphaned @@ -173,27 +212,30 @@ abstract interface class FMTCBackendInternal required String url, }); - /// Register a cache hit or miss on the specified store + /// Register a cache hit or miss on the specified stores, or all stores if + /// null or empty Future registerHitOrMiss({ - required String storeName, + required List? storeNames, required bool hit, }); - /// Remove tiles in excess of the specified limit from the specified store, - /// oldest first + /// Remove tiles in excess of the specified limit in each specified store, + /// oldest tile first /// /// Should internally debounce, as this is a repeatedly invoked & potentially /// expensive operation, that will have no effect when the number of tiles in /// the store is below the limit. /// /// Returns the number of tiles that were actually deleted (they were - /// orphaned (see [deleteTile] for more info)). + /// orphaned (see [deleteTile] for more info)) for each store. /// - /// Throws [RootUnavailable] if the root is uninitialised whilst the + /// If a store does not appear in the output, but was inputted, the store + /// likely did not have a tile limit, in which case no tiles were removed. + /// + /// May throw [RootUnavailable] if the root is uninitialised whilst the /// debouncing mechanism is running. - Future removeOldestTilesAboveLimit({ - required String storeName, - required int tilesLimit, + Future> removeOldestTilesAboveLimit({ + required List storeNames, }); /// {@template fmtc.backend.removeTilesOlderThan} diff --git a/lib/src/backend/interfaces/models.dart b/lib/src/backend/interfaces/models.dart index cd764328..0acb43a3 100644 --- a/lib/src/backend/interfaces/models.dart +++ b/lib/src/backend/interfaces/models.dart @@ -5,7 +5,6 @@ import 'dart:typed_data'; import 'package:meta/meta.dart'; -import '../../../flutter_map_tile_caching.dart'; import '../../misc/obscure_query_params.dart'; /// Represents a tile (which is never directly exposed to the user) @@ -22,7 +21,7 @@ abstract base class BackendTile { /// The time at which the [bytes] of this tile were last changed /// /// This must be kept up to date, otherwise unexpected behaviour may occur - /// when the [FMTCTileProviderSettings.maxStoreLength] is exceeded. + /// when the store's `maxLength` is exceeded. DateTime get lastModified; /// The raw bytes of the image of this tile diff --git a/lib/src/bulk_download/manager.dart b/lib/src/bulk_download/manager.dart index 867aa177..fb67a932 100644 --- a/lib/src/bulk_download/manager.dart +++ b/lib/src/bulk_download/manager.dart @@ -179,7 +179,7 @@ Future _downloadManager( id: recoveryId, storeName: input.storeName, region: input.region, - endTile: min(input.region.end ?? largestInt, maxTiles), + endTile: math.min(input.region.end ?? largestInt, maxTiles), ); send(2); } diff --git a/lib/src/providers/image_provider.dart b/lib/src/providers/image_provider.dart index fac44747..54e82be7 100644 --- a/lib/src/providers/image_provider.dart +++ b/lib/src/providers/image_provider.dart @@ -1,26 +1,14 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:http/http.dart'; - -import '../../flutter_map_tile_caching.dart'; -import '../backend/export_internal.dart'; -import '../misc/obscure_query_params.dart'; +part of '../../flutter_map_tile_caching.dart'; /// A specialised [ImageProvider] that uses FMTC internals to enable browse /// caching -class FMTCImageProvider extends ImageProvider { +class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { /// Create a specialised [ImageProvider] that uses FMTC internals to enable /// browse caching - FMTCImageProvider({ + _FMTCImageProvider({ required this.provider, required this.options, required this.coords, @@ -45,13 +33,13 @@ class FMTCImageProvider extends ImageProvider { /// Function invoked when the image completes loading bytes from the network /// - /// Used with [finishedLoadingBytes] to safely dispose of the `httpClient` only + /// Used with [startedLoading] to safely dispose of the `httpClient` only /// after all tiles have loaded. final void Function() finishedLoadingBytes; @override ImageStreamCompleter loadImage( - FMTCImageProvider key, + _FMTCImageProvider key, ImageDecoderCallback decode, ) { final chunkEvents = StreamController(); @@ -61,7 +49,7 @@ class FMTCImageProvider extends ImageProvider { scale: 1, debugLabel: coords.toString(), informationCollector: () => [ - DiagnosticsProperty('Store name', provider.storeName), + DiagnosticsProperty('Store names', provider.storeNames), DiagnosticsProperty('Tile coordinates', coords), DiagnosticsProperty('Current provider', key), ], @@ -69,63 +57,112 @@ class FMTCImageProvider extends ImageProvider { } Future _loadAsync( - FMTCImageProvider key, + _FMTCImageProvider key, StreamController chunkEvents, ImageDecoderCallback decode, ) async { - Future finishWithError(FMTCBrowsingError err) async { + void close() { scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); unawaited(chunkEvents.close()); finishedLoadingBytes(); + } - provider.settings.errorHandler?.call(err); - throw err; + startedLoading(); + + final Uint8List bytes; + try { + bytes = await getBytes( + coords: coords, + options: options, + provider: provider, + chunkEvents: chunkEvents, + ); + } catch (err, stackTrace) { + close(); + if (err is FMTCBrowsingError) provider.settings.errorHandler?.call(err); + Error.throwWithStackTrace(err, stackTrace); } - Future finishSuccessfully({ - required Uint8List bytes, - required bool cacheHit, - }) async { - scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); - unawaited(chunkEvents.close()); - finishedLoadingBytes(); + close(); + return decode(await ImmutableBuffer.fromUint8List(bytes)); + } - unawaited( + /// {@template fmtc.imageProvider.getBytes} + /// Use FMTC's caching logic to get the bytes of the specific tile (at + /// [coords]) with the specified [TileLayer] options and [FMTCTileProvider] + /// provider + /// + /// Used internally by [_FMTCImageProvider._loadAsync]. + /// + /// However, can also be used externally to integrate FMTC caching into a 3rd + /// party [TileProvider], other than [FMTCTileProvider]. For example, this + /// enables partial compatibility with `VectorTileProvider`s. For more details + /// about compatibility with vector tiles, check the online documentation. + /// + /// --- + /// + /// [requireValidImage] should be left `true` as default when the bytes will + /// form a valid image that Flutter can decode. Set it `false` when the bytes + /// are not decodable by Flutter - for example with vector tiles. Invalid + /// images are never written to the cache. If this is `true`, and the image is + /// invalid, an [FMTCBrowsingError] with sub-category + /// [FMTCBrowsingErrorType.invalidImageData] will be thrown - if `false`, then + /// FMTC will not throw an error, but Flutter will if the bytes are attempted + /// to be decoded. + /// + /// [chunkEvents] is intended to be passed when this is being used inside + /// another [ImageProvider]. Chunk events will be added to it as bytes load. + /// It will not be closed by this method. + /// {@endtemplate} + static Future getBytes({ + required TileCoordinates coords, + required TileLayer options, + required FMTCTileProvider provider, + StreamController? chunkEvents, + bool requireValidImage = true, + }) async { + void registerHit(List storeNames) { + if (provider.settings.recordHitsAndMisses) { FMTCBackendAccess.internal - .registerHitOrMiss(storeName: provider.storeName, hit: cacheHit), - ); - return decode(await ImmutableBuffer.fromUint8List(bytes)); + .registerHitOrMiss(storeNames: storeNames, hit: true); + } + } + + void registerMiss() { + if (provider.settings.recordHitsAndMisses) { + FMTCBackendAccess.internal + .registerHitOrMiss(storeNames: provider.storeNames, hit: false); + } } - Future attemptFinishViaAltStore(String matcherUrl) async { + Future attemptFinishViaAltStore(String matcherUrl) async { if (provider.settings.fallbackToAlternativeStore) { final existingTileAltStore = await FMTCBackendAccess.internal.readTile(url: matcherUrl); if (existingTileAltStore == null) return null; - return finishSuccessfully( - bytes: existingTileAltStore.bytes, - cacheHit: false, - ); + registerMiss(); + return existingTileAltStore.bytes; } return null; } - startedLoading(); - final networkUrl = provider.getTileUrl(coords, options); final matcherUrl = obscureQueryParams( url: networkUrl, obscuredQueryParams: provider.settings.obscuredQueryParams, ); - final existingTile = await FMTCBackendAccess.internal.readTile( + final (tile: existingTile, storeNames: existingStores) = + await FMTCBackendAccess.internal.readTileWithStoreNames( url: matcherUrl, - storeName: provider.storeName, + storeNames: provider.storeNames, ); final needsCreating = existingTile == null; final needsUpdating = !needsCreating && (provider.settings.behavior == CacheBehavior.onlineFirst || + // Tile will not be written if *NoUpdate, regardless of this value + provider.settings.behavior == CacheBehavior.onlineFirstNoUpdate || (provider.settings.cachedValidDuration != Duration.zero && DateTime.timestamp().millisecondsSinceEpoch - existingTile.lastModified.millisecondsSinceEpoch > @@ -138,69 +175,66 @@ class FMTCImageProvider extends ImageProvider { // If there is a cached tile that's in date available, use it if (!needsCreating && !needsUpdating) { - return finishSuccessfully(bytes: bytes!, cacheHit: true); + registerHit(existingStores); + return bytes!; } // If a tile is not available and cache only mode is in use, just fail // before attempting a network call if (provider.settings.behavior == CacheBehavior.cacheOnly && needsCreating) { - final codec = await attemptFinishViaAltStore(matcherUrl); - if (codec != null) return codec; + final altBytes = await attemptFinishViaAltStore(matcherUrl); + if (altBytes != null) return altBytes; - return finishWithError( - FMTCBrowsingError( - type: FMTCBrowsingErrorType.missingInCacheOnlyMode, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - ), + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.missingInCacheOnlyMode, + networkUrl: networkUrl, + matcherUrl: matcherUrl, ); } // Setup a network request for the tile & handle network exceptions - final request = Request('GET', Uri.parse(networkUrl)) + final request = http.Request('GET', Uri.parse(networkUrl)) ..headers.addAll(provider.headers); - final StreamedResponse response; + final http.StreamedResponse response; try { response = await provider.httpClient.send(request); } catch (e) { if (!needsCreating) { - return finishSuccessfully(bytes: bytes!, cacheHit: false); + registerMiss(); + return bytes!; } - final codec = await attemptFinishViaAltStore(matcherUrl); - if (codec != null) return codec; - - return finishWithError( - FMTCBrowsingError( - type: e is SocketException - ? FMTCBrowsingErrorType.noConnectionDuringFetch - : FMTCBrowsingErrorType.unknownFetchException, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - request: request, - originalError: e, - ), + final altBytes = await attemptFinishViaAltStore(matcherUrl); + if (altBytes != null) return altBytes; + + throw FMTCBrowsingError( + type: e is SocketException + ? FMTCBrowsingErrorType.noConnectionDuringFetch + : FMTCBrowsingErrorType.unknownFetchException, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + request: request, + originalError: e, ); } // Check whether the network response is not 200 OK if (response.statusCode != 200) { if (!needsCreating) { - return finishSuccessfully(bytes: bytes!, cacheHit: false); + registerMiss(); + return bytes!; } - final codec = await attemptFinishViaAltStore(matcherUrl); - if (codec != null) return codec; + final altBytes = await attemptFinishViaAltStore(matcherUrl); + if (altBytes != null) return altBytes; - return finishWithError( - FMTCBrowsingError( - type: FMTCBrowsingErrorType.negativeFetchResponse, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - request: request, - response: response, - ), + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.negativeFetchResponse, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + request: request, + response: response, ); } @@ -208,7 +242,7 @@ class FMTCImageProvider extends ImageProvider { final bytesBuilder = BytesBuilder(copy: false); await for (final byte in response.stream) { bytesBuilder.add(byte); - chunkEvents.add( + chunkEvents?.add( ImageChunkEvent( cumulativeBytesLoaded: bytesBuilder.length, expectedTotalBytes: response.contentLength, @@ -235,53 +269,61 @@ class FMTCImageProvider extends ImageProvider { } if (!isValidImageData) { if (!needsCreating) { - return finishSuccessfully(bytes: bytes!, cacheHit: false); + registerMiss(); + return bytes!; } - final codec = await attemptFinishViaAltStore(matcherUrl); - if (codec != null) return codec; + final altBytes = await attemptFinishViaAltStore(matcherUrl); + if (altBytes != null) return altBytes; - return finishWithError( - FMTCBrowsingError( + if (requireValidImage) { + throw FMTCBrowsingError( type: FMTCBrowsingErrorType.invalidImageData, networkUrl: networkUrl, matcherUrl: matcherUrl, request: request, response: response, - ), - ); + ); + } else { + registerMiss(); + return responseBytes; + } } - // Cache the tile retrieved from the network response - unawaited( - FMTCBackendAccess.internal.writeTile( - storeName: provider.storeName, - url: matcherUrl, - bytes: responseBytes, - ), - ); - - // Clear out old tiles if the maximum store length has been exceeded - if (needsCreating && provider.settings.maxStoreLength != 0) { + // Cache the tile retrieved from the network response, if behaviour allows + if (provider.settings.behavior != CacheBehavior.cacheFirstNoUpdate && + provider.settings.behavior != CacheBehavior.onlineFirstNoUpdate && + (provider.storeNames?.isNotEmpty ?? false)) { unawaited( - FMTCBackendAccess.internal.removeOldestTilesAboveLimit( - storeName: provider.storeName, - tilesLimit: provider.settings.maxStoreLength, + FMTCBackendAccess.internal.writeTile( + storeNames: provider.storeNames!, + url: matcherUrl, + bytes: responseBytes, ), ); + + // Clear out old tiles if the maximum store length has been exceeded + if (needsCreating) { + unawaited( + FMTCBackendAccess.internal.removeOldestTilesAboveLimit( + storeNames: provider.storeNames!, + ), + ); + } } - return finishSuccessfully(bytes: responseBytes, cacheHit: false); + registerMiss(); + return responseBytes; } @override - Future obtainKey(ImageConfiguration configuration) => - SynchronousFuture(this); + Future<_FMTCImageProvider> obtainKey(ImageConfiguration configuration) => + SynchronousFuture<_FMTCImageProvider>(this); @override bool operator ==(Object other) => identical(this, other) || - (other is FMTCImageProvider && + (other is _FMTCImageProvider && other.runtimeType == runtimeType && other.coords == coords && other.provider == provider && diff --git a/lib/src/providers/tile_provider.dart b/lib/src/providers/tile_provider.dart index c25557fa..a66b4a8c 100644 --- a/lib/src/providers/tile_provider.dart +++ b/lib/src/providers/tile_provider.dart @@ -4,20 +4,29 @@ part of '../../flutter_map_tile_caching.dart'; /// Specialised [TileProvider] that uses a specialised [ImageProvider] to connect -/// to FMTC internals +/// to FMTC internals and enable advanced caching/retrieval logic /// /// An "FMTC" identifying mark is injected into the "User-Agent" header generated /// by flutter_map, except if specified in the constructor. For technical /// details, see [_CustomUserAgentCompatMap]. -/// -/// Create from the store directory chain, eg. [FMTCStore.getTileProvider]. class FMTCTileProvider extends TileProvider { - FMTCTileProvider._( - this.storeName, + /// Create a specialised [TileProvider] that uses a specialised [ImageProvider] + /// to connect to FMTC internals and enable advanced caching/retrieval logic + /// + /// Supports multiple stores, by specifying each name in [storeNames]. If an + /// empty list is specified, tiles will be fetched from all stores, but no + /// tiles will be written to any stores. For more information, see + /// [storeNames]. + /// Can be constructed alternatively with [FMTCStore.getTileProvider] to + /// support a single store. + /// + /// See other documentation for more information. + FMTCTileProvider({ + required this.storeNames, FMTCTileProviderSettings? settings, Map? headers, http.Client? httpClient, - ) : settings = settings ?? FMTCTileProviderSettings.instance, + }) : settings = settings ?? FMTCTileProviderSettings.instance, httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), super( headers: (headers?.containsKey('User-Agent') ?? false) @@ -25,8 +34,36 @@ class FMTCTileProvider extends TileProvider { : _CustomUserAgentCompatMap(headers ?? {}), ); - /// The store name of the [FMTCStore] used when generating this provider - final String storeName; + /// Create a specialised [TileProvider] that uses a specialised [ImageProvider] + /// to connect to FMTC internals and enable advanced caching/retrieval logic + /// + /// Redirects to [FMTCTileProvider] constructor, but supports [FMTCStore] + /// instead of [String]. + FMTCTileProvider.fromStores({ + required List stores, + FMTCTileProviderSettings? settings, + Map? headers, + http.Client? httpClient, + }) : this( + storeNames: stores.map((s) => s.storeName).toList(), + settings: settings, + headers: headers, + httpClient: httpClient, + ); + + /// The store names from which to fetch tiles and update tiles + /// + /// If empty, tiles will be fetched from all stores, but no tiles will be + /// written to any stores (regardless of [FMTCTileProviderSettings.behavior]). + /// This may introduce notable performance reductions, especially if failures + /// occur often or the root is particularly large, as the tile queries will + /// have unbounded constraints. + /// + /// See also: + /// - [FMTCTileProviderSettings.fallbackToAlternativeStore], which has a + /// similar behaviour, but only does so when the tile cannot be found in + /// these stores + final List? storeNames; /// The tile provider settings to use /// @@ -47,11 +84,11 @@ class FMTCTileProvider extends TileProvider { /// underway. /// /// Does not include tiles loaded from session cache. - final _tilesInProgress = HashMap>(); + final _tilesInProgress = HashMap>.identity(); @override ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => - FMTCImageProvider( + _FMTCImageProvider( provider: this, options: options, coords: coordinates, @@ -71,6 +108,21 @@ class FMTCTileProvider extends TileProvider { super.dispose(); } + /// {@macro fmtc.imageProvider.getBytes} + Future getBytes({ + required TileCoordinates coordinates, + required TileLayer options, + StreamController? chunkEvents, + bool requireValidImage = true, + }) => + _FMTCImageProvider.getBytes( + provider: this, + options: options, + coords: coordinates, + chunkEvents: chunkEvents, + requireValidImage: requireValidImage, + ); + /// Check whether a specified tile is cached in the current store @Deprecated(''' Migrate to `checkTileCached`. @@ -89,8 +141,8 @@ member will be removed in a future version.''') required TileCoordinates coords, required TileLayer options, }) => - FMTCBackendAccess.internal.tileExistsInStore( - storeName: storeName, + FMTCBackendAccess.internal.tileExists( + storeNames: storeNames, url: obscureQueryParams( url: getTileUrl(coords, options), obscuredQueryParams: settings.obscuredQueryParams, @@ -101,13 +153,13 @@ member will be removed in a future version.''') bool operator ==(Object other) => identical(this, other) || (other is FMTCTileProvider && - other.storeName == storeName && + other.storeNames == storeNames && other.headers == headers && other.settings == settings && other.httpClient == httpClient); @override - int get hashCode => Object.hash(storeName, settings, headers, httpClient); + int get hashCode => Object.hash(storeNames, settings, headers, httpClient); } /// Custom override of [Map] that only overrides the [MapView.putIfAbsent] diff --git a/lib/src/providers/tile_provider_settings.dart b/lib/src/providers/tile_provider_settings.dart index e482a52a..5220cf96 100644 --- a/lib/src/providers/tile_provider_settings.dart +++ b/lib/src/providers/tile_provider_settings.dart @@ -8,35 +8,49 @@ typedef FMTCBrowsingErrorHandler = void Function(FMTCBrowsingError exception); /// Behaviours dictating how and when browse caching should occur /// -/// An online only behaviour is not available: use a default [TileProvider] to -/// achieve this. +/// | `CacheBehavior` | Preferred fetch method | Fallback fetch method | Update cache when network used | +/// |--------------------------|------------------------|-----------------------|--------------------------------| +/// | `cacheOnly` | Cache | None | | +/// | `cacheFirst` | Cache | Network | Yes | +/// | `cacheFirstNoUpdate` | Cache | Network | No | +/// | `onlineFirst` | Network | Cache | Yes | +/// | `onlineFirstNoUpdate` | Network | Cache | No | +/// | *Standard Tile Provider* | *Network* | *None* | *No* | enum CacheBehavior { - /// Only get tiles from the local cache + /// Only fetch tiles from the local cache /// /// Throws [FMTCBrowsingErrorType.missingInCacheOnlyMode] if a tile is /// unavailable. /// - /// If [FMTCTileProviderSettings.fallbackToAlternativeStore] is enabled, cached - /// tiles may also be taken from other stores. + /// See documentation on [CacheBehavior] for behavior comparison table. cacheOnly, - /// Retrieve tiles from the cache, only using the network to update the cached - /// tile if it has expired + /// Fetch tiles from the cache, falling back to the network to fetch and + /// create/update non-existent/expired tiles /// - /// Falls back to using cached tiles if the network is not available. - /// - /// If [FMTCTileProviderSettings.fallbackToAlternativeStore] is enabled, and - /// the network is unavailable, cached tiles may also be taken from other - /// stores. + /// See documentation on [CacheBehavior] for behavior comparison table. cacheFirst, - /// Get tiles from the network where possible, and update the cached tiles + /// Fetch tiles from the cache, falling back to the network to fetch + /// non-existent tiles + /// + /// Never updates the cache, even if the network is used to fetch the tile. /// - /// Falls back to using cached tiles if the network is unavailable. + /// See documentation on [CacheBehavior] for behavior comparison table. + cacheFirstNoUpdate, + + /// Fetch and create/update non-existent/expired tiles from the network, + /// falling back to the cache to fetch tiles /// - /// If [FMTCTileProviderSettings.fallbackToAlternativeStore] is enabled, cached - /// tiles may also be taken from other stores. + /// See documentation on [CacheBehavior] for behavior comparison table. onlineFirst, + + /// Fetch tiles from the network, falling back to the cache to fetch tiles + /// + /// Never updates the cache, even if the network is used to fetch the tile. + /// + /// See documentation on [CacheBehavior] for behavior comparison table. + onlineFirstNoUpdate, } /// Settings for an [FMTCTileProvider] @@ -52,7 +66,7 @@ class FMTCTileProviderSettings { CacheBehavior behavior = CacheBehavior.cacheFirst, bool fallbackToAlternativeStore = true, Duration cachedValidDuration = const Duration(days: 16), - int maxStoreLength = 0, + bool trackHitsAndMisses = true, List obscuredQueryParams = const [], FMTCBrowsingErrorHandler? errorHandler, bool setInstance = true, @@ -60,8 +74,8 @@ class FMTCTileProviderSettings { final settings = FMTCTileProviderSettings._( behavior: behavior, fallbackToAlternativeStore: fallbackToAlternativeStore, + recordHitsAndMisses: trackHitsAndMisses, cachedValidDuration: cachedValidDuration, - maxStoreLength: maxStoreLength, obscuredQueryParams: obscuredQueryParams.map((e) => RegExp('$e=[^&]*')), errorHandler: errorHandler, ); @@ -74,7 +88,7 @@ class FMTCTileProviderSettings { required this.behavior, required this.cachedValidDuration, required this.fallbackToAlternativeStore, - required this.maxStoreLength, + required this.recordHitsAndMisses, required this.obscuredQueryParams, required this.errorHandler, }); @@ -92,10 +106,10 @@ class FMTCTileProviderSettings { /// Whether to retrieve a tile from another store if it exists, as a fallback, /// instead of throwing an error /// - /// Does not add tiles taken from other stores to the specified store. + /// Does not add tiles taken from other stores to the specified store(s). /// /// When tiles are retrieved from other stores, it is counted as a miss for the - /// specified store. + /// specified store(s). /// /// This may introduce notable performance reductions, especially if failures /// occur often or the root is particularly large, as an extra lookup with @@ -107,24 +121,25 @@ class FMTCTileProviderSettings { /// Defaults to `true`. final bool fallbackToAlternativeStore; + /// Whether to keep track of the [StoreStats.hits] and [StoreStats.misses] + /// statistics + /// + /// When enabled, hits will be recorded for all stores that the tile belonged + /// to and were present in [FMTCTileProvider.storeNames], when necessary. + /// Misses will be recorded for all stores specified in the tile provided, + /// where necessary + /// + /// Disable to improve performance and/or if these statistics are never used. + /// + /// Defaults to `true`. + final bool recordHitsAndMisses; + /// The duration until a tile expires and needs to be fetched again when /// browsing. Also called `validDuration`. /// /// Defaults to 16 days, set to [Duration.zero] to disable. final Duration cachedValidDuration; - /// The maximum number of tiles allowed in a cache store (only whilst - /// 'browsing' - see below) before the oldest tile gets deleted. Also called - /// `maxTiles`. - /// - /// Only applies to 'browse caching', ie. downloading regions will bypass this - /// limit. - /// - /// Note that the database maximum size may be set by the backend. - /// - /// Defaults to 0 disabled. - final int maxStoreLength; - /// A list of regular expressions indicating key-value pairs to be remove from /// a URL's query parameter list /// @@ -149,7 +164,8 @@ class FMTCTileProviderSettings { (other is FMTCTileProviderSettings && other.behavior == behavior && other.cachedValidDuration == cachedValidDuration && - other.maxStoreLength == maxStoreLength && + other.fallbackToAlternativeStore == fallbackToAlternativeStore && + other.recordHitsAndMisses == recordHitsAndMisses && other.errorHandler == errorHandler && other.obscuredQueryParams == obscuredQueryParams); @@ -157,7 +173,8 @@ class FMTCTileProviderSettings { int get hashCode => Object.hashAllUnordered([ behavior, cachedValidDuration, - maxStoreLength, + fallbackToAlternativeStore, + recordHitsAndMisses, errorHandler, obscuredQueryParams, ]); diff --git a/lib/src/store/manage.dart b/lib/src/store/manage.dart index e8edbacc..4b671d39 100644 --- a/lib/src/store/manage.dart +++ b/lib/src/store/manage.dart @@ -13,13 +13,27 @@ class StoreManagement { StoreManagement._(this._storeName); final String _storeName; + /// {@macro fmtc.backend.storeGetMaxLength} + Future get maxLength => + FMTCBackendAccess.internal.storeGetMaxLength(storeName: _storeName); + + /// {@macro fmtc.backend.storeSetMaxLength} + Future setMaxLength(int? newMaxLength) => + FMTCBackendAccess.internal.storeSetMaxLength( + storeName: _storeName, + newMaxLength: newMaxLength, + ); + /// {@macro fmtc.backend.storeExists} Future get ready => FMTCBackendAccess.internal.storeExists(storeName: _storeName); /// {@macro fmtc.backend.createStore} - Future create() => - FMTCBackendAccess.internal.createStore(storeName: _storeName); + Future create({int? maxLength}) => + FMTCBackendAccess.internal.createStore( + storeName: _storeName, + maxLength: maxLength, + ); /// {@macro fmtc.backend.deleteStore} Future delete() => diff --git a/lib/src/store/statistics.dart b/lib/src/store/statistics.dart index f59119af..d6492a0f 100644 --- a/lib/src/store/statistics.dart +++ b/lib/src/store/statistics.dart @@ -37,11 +37,16 @@ class StoreStats { /// Retrieve the number of successful tile retrievals when browsing /// + /// A hit is only counted when an unexpired tile is retrieved from the store. + /// /// {@macro fmtc.frontend.storestats.efficiency} Future get hits => all.then((a) => a.hits); /// Retrieve the number of unsuccessful tile retrievals when browsing /// + /// A miss is counted whenever a tile is retrieved anywhere else but from this + /// store, or is retrieved from this store, but only as a fallback. + /// /// {@macro fmtc.frontend.storestats.efficiency} Future get misses => all.then((a) => a.misses); diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index ea88e25f..31c745f3 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -1,6 +1,8 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE +// ignore_for_file: use_late_for_private_fields_and_variables + part of '../../flutter_map_tile_caching.dart'; /// Equivalent to [FMTCStore], provided to ease migration only @@ -60,7 +62,12 @@ class FMTCStore { Map? headers, http.Client? httpClient, }) => - FMTCTileProvider._(storeName, settings, headers, httpClient); + FMTCTileProvider( + storeNames: [storeName], + settings: settings, + headers: headers, + httpClient: httpClient, + ); @override bool operator ==(Object other) => diff --git a/test/general_test.dart b/test/general_test.dart index fd0bc64f..909f1dc3 100644 --- a/test/general_test.dart +++ b/test/general_test.dart @@ -311,7 +311,7 @@ void main() { 'Write tile (A64) to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], url: tileA64.url, bytes: tileA64.bytes, ); @@ -334,7 +334,7 @@ void main() { 'Write tile (A64) again to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], url: tileA64.url, bytes: tileA64.bytes, ); @@ -357,7 +357,7 @@ void main() { 'Write tile (A128) to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], url: tileA128.url, bytes: tileA128.bytes, ); @@ -380,7 +380,7 @@ void main() { 'Write tile (B64) to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], url: tileB64.url, bytes: tileB64.bytes, ); @@ -403,7 +403,7 @@ void main() { 'Write tile (B128) to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], url: tileB128.url, bytes: tileB128.bytes, ); @@ -426,7 +426,7 @@ void main() { 'Write tile (B64) again to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], url: tileB64.url, bytes: tileB64.bytes, ); @@ -471,7 +471,7 @@ void main() { 'Write tile (A64) to "store2"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store2', + storeNames: ['store2'], url: tileA64.url, bytes: tileA64.bytes, ); @@ -504,7 +504,7 @@ void main() { 'Write tile (A128) to "store2"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store2', + storeNames: ['store2'], url: tileA128.url, bytes: tileA128.bytes, ); @@ -564,7 +564,7 @@ void main() { 'Write tile (B64) to "store2"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store2', + storeNames: ['store2'], url: tileB64.url, bytes: tileB64.bytes, ); @@ -597,7 +597,7 @@ void main() { 'Write tile (A64) to "store2"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store2', + storeNames: ['store2'], url: tileA64.url, bytes: tileA64.bytes, ); @@ -627,17 +627,41 @@ void main() { ); test( - 'Reset "store2"', + 'Reset stores', () async { + await const FMTCStore('store1').manage.reset(); await const FMTCStore('store2').manage.reset(); expect( await const FMTCStore('store1').stats.all, - (length: 1, size: 0.0625, hits: 0, misses: 0), + (length: 0, size: 0, hits: 0, misses: 0), ); expect( await const FMTCStore('store2').stats.all, (length: 0, size: 0, hits: 0, misses: 0), ); + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect(await const FMTCStore('store1').stats.tileImage(), null); + expect(await const FMTCStore('store2').stats.tileImage(), null); + }, + ); + + test( + 'Write tile (A64) to "store1" & "store2"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeNames: ['store1', 'store2'], + url: tileA64.url, + bytes: tileA64.bytes, + ); + expect( + await const FMTCStore('store1').stats.all, + (length: 1, size: 0.0625, hits: 0, misses: 0), + ); + expect( + await const FMTCStore('store2').stats.all, + (length: 1, size: 0.0625, hits: 0, misses: 0), + ); expect(await FMTCRoot.stats.length, 1); expect(await FMTCRoot.stats.size, 0.0625); expect( @@ -646,7 +670,110 @@ void main() { ?.bytes, tileA64.bytes, ); - expect(await const FMTCStore('store2').stats.tileImage(), null); + expect( + ((await const FMTCStore('store2').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA64.bytes, + ); + }, + ); + + test( + 'Write tile (A128) to "store1" & "store2"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeNames: ['store1', 'store2'], + url: tileA128.url, + bytes: tileA128.bytes, + ); + expect( + await const FMTCStore('store1').stats.all, + (length: 1, size: 0.125, hits: 0, misses: 0), + ); + expect( + await const FMTCStore('store2').stats.all, + (length: 1, size: 0.125, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 1); + expect(await FMTCRoot.stats.size, 0.125); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA128.bytes, + ); + expect( + ((await const FMTCStore('store2').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA128.bytes, + ); + }, + ); + + test( + 'Write tile (B128) to "store1" & "store2"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeNames: ['store1', 'store2'], + url: tileB128.url, + bytes: tileB128.bytes, + ); + expect( + await const FMTCStore('store1').stats.all, + (length: 2, size: 0.25, hits: 0, misses: 0), + ); + expect( + await const FMTCStore('store2').stats.all, + (length: 2, size: 0.25, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 2); + expect(await FMTCRoot.stats.size, 0.25); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileB128.bytes, + ); + expect( + ((await const FMTCStore('store2').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileB128.bytes, + ); + }, + ); + + test( + 'Delete tile (A(128)) from "store1"', + () async { + await FMTCBackendAccess.internal.deleteTile( + storeName: 'store1', + url: tileA128.url, + ); + expect( + await const FMTCStore('store1').stats.all, + (length: 1, size: 0.125, hits: 0, misses: 0), + ); + expect( + await const FMTCStore('store2').stats.all, + (length: 2, size: 0.25, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 2); + expect(await FMTCRoot.stats.size, 0.25); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileB128.bytes, + ); + expect( + ((await const FMTCStore('store2').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileB128.bytes, + ); }, ); From 692fb6b27fc9da10d7ed843dcb717a251a27941d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 28 May 2024 10:12:30 +0100 Subject: [PATCH 02/97] Updated version to v10 --- example/android/app/build.gradle | 4 ++-- example/pubspec.yaml | 2 +- pubspec.yaml | 2 +- windowsApplicationInstallerSetup.iss | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 6408bdf1..8e9ee46f 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -15,12 +15,12 @@ if (localPropertiesFile.exists()) { def flutterVersionCode = localProperties.getProperty("flutter.versionCode") if (flutterVersionCode == null) { - flutterVersionCode = "9" + flutterVersionCode = "10" } def flutterVersionName = localProperties.getProperty("flutter.versionName") if (flutterVersionName == null) { - flutterVersionName = "9.0" + flutterVersionName = "10.0" } android { diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 83ed8b6d..860c6ad4 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -3,7 +3,7 @@ description: The example application for 'flutter_map_tile_caching', showcasing it's functionality and use-cases. publish_to: "none" -version: 9.1.0 +version: 10.0.0 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 60da76b6..6fbb7e16 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 9.1.0 +version: 10.0.0 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index 72c9778f..36eda351 100644 --- a/windowsApplicationInstallerSetup.iss +++ b/windowsApplicationInstallerSetup.iss @@ -1,7 +1,7 @@ ; Script generated by the Inno Setup Script Wizard #define MyAppName "FMTC Demo" -#define MyAppVersion "for 9.1.0" +#define MyAppVersion "for 10.0.0" #define MyAppPublisher "JaffaKetchup Development" #define MyAppURL "https://github.com/JaffaKetchup/flutter_map_tile_caching" #define MyAppSupportURL "https://github.com/JaffaKetchup/flutter_map_tile_caching/issues" From 9c0ca702313946b60e6ef73c3a705ed5d52eae4d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 28 May 2024 10:14:41 +0100 Subject: [PATCH 03/97] Removed deprecations --- lib/flutter_map_tile_caching.dart | 1 - lib/src/misc/deprecations.dart | 199 --------------------------- lib/src/providers/tile_provider.dart | 13 -- lib/src/regions/base_region.dart | 24 ---- lib/src/regions/circle.dart | 42 ------ lib/src/regions/custom_polygon.dart | 42 ------ lib/src/regions/line.dart | 69 ---------- lib/src/regions/rectangle.dart | 42 ------ lib/src/root/root.dart | 15 -- lib/src/store/store.dart | 15 -- 10 files changed, 462 deletions(-) delete mode 100644 lib/src/misc/deprecations.dart diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 23640404..6bf48620 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -45,7 +45,6 @@ part 'src/bulk_download/download_progress.dart'; part 'src/bulk_download/manager.dart'; part 'src/bulk_download/thread.dart'; part 'src/bulk_download/tile_event.dart'; -part 'src/misc/deprecations.dart'; part 'src/providers/image_provider.dart'; part 'src/providers/tile_provider.dart'; part 'src/providers/tile_provider_settings.dart'; diff --git a/lib/src/misc/deprecations.dart b/lib/src/misc/deprecations.dart deleted file mode 100644 index 30eccac3..00000000 --- a/lib/src/misc/deprecations.dart +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of '../../flutter_map_tile_caching.dart'; - -const _syncRemoval = ''' - -Synchronous operations have been removed throughout FMTC v9, therefore the distinction between sync and async operations has been removed. -This deprecated member will be removed in a future version. -'''; - -//! ROOT !// - -/// Provides deprecations where possible for previous methods in [RootStats] -/// after the v9 release. -/// -/// Synchronous operations have been removed throughout FMTC v9, therefore the -/// distinction between sync and async operations has been removed. -/// -/// Provided in an extension method for easy differentiation and quick removal. -@Deprecated( - 'Migrate to the suggested replacements for each operation. $_syncRemoval', -) -extension RootStatsDeprecations on RootStats { - /// {@macro fmtc.backend.listStores} - @Deprecated('Migrate to `storesAvailable`. $_syncRemoval') - Future> get storesAvailableAsync => storesAvailable; - - /// {@macro fmtc.backend.rootSize} - @Deprecated('Migrate to `size`. $_syncRemoval') - Future get rootSizeAsync => size; - - /// {@macro fmtc.backend.rootLength} - @Deprecated('Migrate to `length`. $_syncRemoval') - Future get rootLengthAsync => length; -} - -/// Provides deprecations where possible for previous methods in [RootRecovery] -/// after the v9 release. -/// -/// Synchronous operations have been removed throughout FMTC v9, therefore the -/// distinction between sync and async operations has been removed. -/// -/// Provided in an extension method for easy differentiation and quick removal. -@Deprecated( - 'Migrate to the suggested replacements for each operation. $_syncRemoval', -) -extension RootRecoveryDeprecations on RootRecovery { - /// List all failed failed downloads - /// - /// {@macro fmtc.rootRecovery.failedDefinition} - @Deprecated('Migrate to `recoverableRegions.failedOnly`. $_syncRemoval') - Future> get failedRegions => - recoverableRegions.then((e) => e.failedOnly.toList()); -} - -//! STORE !// - -/// Provides deprecations where possible for previous methods in -/// [StoreManagement] after the v9 release. -/// -/// Synchronous operations have been removed throughout FMTC v9, therefore the -/// distinction between sync and async operations has been removed. -/// -/// Provided in an extension method for easy differentiation and quick removal. -@Deprecated( - 'Migrate to the suggested replacements for each operation. $_syncRemoval', -) -extension StoreManagementDeprecations on StoreManagement { - /// {@macro fmtc.backend.createStore} - @Deprecated('Migrate to `create`. $_syncRemoval') - Future createAsync() => create(); - - /// {@macro fmtc.backend.resetStore} - @Deprecated('Migrate to `reset`. $_syncRemoval') - Future resetAsync() => reset(); - - /// {@macro fmtc.backend.deleteStore} - @Deprecated('Migrate to `delete`. $_syncRemoval') - Future deleteAsync() => delete(); - - /// {@macro fmtc.backend.renameStore} - @Deprecated('Migrate to `rename`. $_syncRemoval') - Future renameAsync(String newStoreName) => rename(newStoreName); -} - -/// Provides deprecations where possible for previous methods in [StoreStats] -/// after the v9 release. -/// -/// Synchronous operations have been removed throughout FMTC v9, therefore the -/// distinction between sync and async operations has been removed. -/// -/// Provided in an extension method for easy differentiation and quick removal. -@Deprecated( - 'Migrate to the suggested replacements for each operation. $_syncRemoval', -) -extension StoreStatsDeprecations on StoreStats { - /// Retrieve the total number of KiBs of all tiles' bytes (not 'real total' - /// size) - /// - /// {@macro fmtc.frontend.storestats.efficiency} - @Deprecated('Migrate to `size`. $_syncRemoval') - Future get storeSizeAsync => size; - - /// Retrieve the number of tiles belonging to this store - /// - /// {@macro fmtc.frontend.storestats.efficiency} - @Deprecated('Migrate to `length`. $_syncRemoval') - Future get storeLengthAsync => length; - - /// Retrieve the number of successful tile retrievals when browsing - /// - /// {@macro fmtc.frontend.storestats.efficiency} - @Deprecated('Migrate to `hits`.$_syncRemoval') - Future get cacheHitsAsync => hits; - - /// Retrieve the number of unsuccessful tile retrievals when browsing - /// - /// {@macro fmtc.frontend.storestats.efficiency} - @Deprecated('Migrate to `misses`. $_syncRemoval') - Future get cacheMissesAsync => misses; - - /// {@macro fmtc.backend.tileImage} - /// , then render the bytes to an [Image] - @Deprecated('Migrate to `tileImage`. $_syncRemoval') - Future tileImageAsync({ - double? size, - Key? key, - double scale = 1.0, - ImageFrameBuilder? frameBuilder, - ImageErrorWidgetBuilder? errorBuilder, - String? semanticLabel, - bool excludeFromSemantics = false, - Color? color, - Animation? opacity, - BlendMode? colorBlendMode, - BoxFit? fit, - AlignmentGeometry alignment = Alignment.center, - ImageRepeat repeat = ImageRepeat.noRepeat, - Rect? centerSlice, - bool matchTextDirection = false, - bool gaplessPlayback = false, - bool isAntiAlias = false, - FilterQuality filterQuality = FilterQuality.low, - int? cacheWidth, - int? cacheHeight, - }) => - tileImage( - size: size, - key: key, - scale: scale, - frameBuilder: frameBuilder, - errorBuilder: errorBuilder, - semanticLabel: semanticLabel, - excludeFromSemantics: excludeFromSemantics, - color: color, - opacity: opacity, - colorBlendMode: colorBlendMode, - fit: fit, - alignment: alignment, - repeat: repeat, - centerSlice: centerSlice, - matchTextDirection: matchTextDirection, - gaplessPlayback: gaplessPlayback, - isAntiAlias: isAntiAlias, - filterQuality: filterQuality, - cacheWidth: cacheWidth, - cacheHeight: cacheHeight, - ); -} - -/// Provides deprecations where possible for previous methods in [StoreMetadata] -/// after the v9 release. -/// -/// Synchronous operations have been removed throughout FMTC v9, therefore the -/// distinction between sync and async operations has been removed. -/// -/// Provided in an extension method for easy differentiation and quick removal. -@Deprecated( - 'Migrate to the suggested replacements for each operation. $_syncRemoval', -) -extension StoreMetadataDeprecations on StoreMetadata { - /// {@macro fmtc.backend.readMetadata} - @Deprecated('Migrate to `read`. $_syncRemoval') - Future> get readAsync => read; - - /// {@macro fmtc.backend.setMetadata} - @Deprecated('Migrate to `set`. $_syncRemoval') - Future addAsync({required String key, required String value}) => - set(key: key, value: value); - - /// {@macro fmtc.backend.removeMetadata} - @Deprecated('Migrate to `remove`.$_syncRemoval') - Future removeAsync({required String key}) => remove(key: key); - - /// {@macro fmtc.backend.resetMetadata} - @Deprecated('Migrate to `reset`. $_syncRemoval') - Future resetAsync() => reset(); -} diff --git a/lib/src/providers/tile_provider.dart b/lib/src/providers/tile_provider.dart index a66b4a8c..f53eb12a 100644 --- a/lib/src/providers/tile_provider.dart +++ b/lib/src/providers/tile_provider.dart @@ -123,19 +123,6 @@ class FMTCTileProvider extends TileProvider { requireValidImage: requireValidImage, ); - /// Check whether a specified tile is cached in the current store - @Deprecated(''' -Migrate to `checkTileCached`. - -Synchronous operations have been removed throughout FMTC v9, therefore the -distinction between sync and async operations has been removed. This deprecated -member will be removed in a future version.''') - Future checkTileCachedAsync({ - required TileCoordinates coords, - required TileLayer options, - }) => - checkTileCached(coords: coords, options: options); - /// Check whether a specified tile is cached in the current store Future checkTileCached({ required TileCoordinates coords, diff --git a/lib/src/regions/base_region.dart b/lib/src/regions/base_region.dart index 88841e90..aef3d4ec 100644 --- a/lib/src/regions/base_region.dart +++ b/lib/src/regions/base_region.dart @@ -55,30 +55,6 @@ sealed class BaseRegion { Crs crs = const Epsg3857(), }); - /// Generate a graphical layer to be placed in a [FlutterMap] - /// - /// **Deprecated.** Instead obtain the outline/line/points using other methods, - /// and render the layer manually. This method is being removed to reduce - /// dependency on flutter_map, and allow full usage of flutter_map - /// functionality without it needing to be semi-implemented here. This feature - /// was deprecated after v9.1.0, and will be removed in the next breaking/major - /// release. - @Deprecated( - 'Instead obtain the outline/line/points using other methods, and render the ' - 'layer manually. ' - 'This method is being removed to reduce dependency on flutter_map, and allow ' - 'full usage of flutter_map functionality without it needing to be ' - 'semi-implemented here. ' - 'This feature was deprecated after v9.1.0, and will be removed in the next ' - 'breaking/major release.', - ) - Widget toDrawable({ - Color? fillColor, - Color borderColor = const Color(0x00000000), - double borderStrokeWidth = 3, - bool isDotted = false, - }); - /// Generate the list of all the [LatLng]s forming the outline of this region /// /// Returns a `Iterable` which can be used anywhere. diff --git a/lib/src/regions/circle.dart b/lib/src/regions/circle.dart index 4b6b8b47..e3ea0b0c 100644 --- a/lib/src/regions/circle.dart +++ b/lib/src/regions/circle.dart @@ -41,48 +41,6 @@ class CircleRegion extends BaseRegion { crs: crs, ); - /// **Deprecated.** Instead obtain the outline/line/points using other methods, - /// and render the layer manually. This method is being removed to reduce - /// dependency on flutter_map, and allow full usage of flutter_map - /// functionality without it needing to be semi-implemented here. This feature - /// was deprecated after v9.1.0, and will be removed in the next breaking/major - /// release. - @Deprecated( - 'Instead obtain the outline/line/points using other methods, and render the ' - 'layer manually. ' - 'This method is being removed to reduce dependency on flutter_map, and allow ' - 'full usage of flutter_map functionality without it needing to be ' - 'semi-implemented here. ' - 'This feature was deprecated after v9.1.0, and will be removed in the next ' - 'breaking/major release.', - ) - @override - PolygonLayer toDrawable({ - Color? fillColor, - Color borderColor = const Color(0x00000000), - double borderStrokeWidth = 3.0, - bool isDotted = false, - String? label, - TextStyle labelStyle = const TextStyle(), - PolygonLabelPlacement labelPlacement = PolygonLabelPlacement.polylabel, - }) => - PolygonLayer( - polygons: [ - Polygon( - points: toOutline().toList(), - color: fillColor, - borderColor: borderColor, - borderStrokeWidth: borderStrokeWidth, - pattern: isDotted - ? const StrokePattern.dotted() - : const StrokePattern.solid(), - label: label, - labelStyle: labelStyle, - labelPlacement: labelPlacement, - ), - ], - ); - @override Iterable toOutline() sync* { const dist = Distance(roundResult: false, calculator: Haversine()); diff --git a/lib/src/regions/custom_polygon.dart b/lib/src/regions/custom_polygon.dart index 319d24d3..a82513f0 100644 --- a/lib/src/regions/custom_polygon.dart +++ b/lib/src/regions/custom_polygon.dart @@ -38,48 +38,6 @@ class CustomPolygonRegion extends BaseRegion { crs: crs, ); - /// **Deprecated.** Instead obtain the outline/line/points using other methods, - /// and render the layer manually. This method is being removed to reduce - /// dependency on flutter_map, and allow full usage of flutter_map - /// functionality without it needing to be semi-implemented here. This feature - /// was deprecated after v9.1.0, and will be removed in the next breaking/major - /// release. - @Deprecated( - 'Instead obtain the outline/line/points using other methods, and render the ' - 'layer manually. ' - 'This method is being removed to reduce dependency on flutter_map, and allow ' - 'full usage of flutter_map functionality without it needing to be ' - 'semi-implemented here. ' - 'This feature was deprecated after v9.1.0, and will be removed in the next ' - 'breaking/major release.', - ) - @override - PolygonLayer toDrawable({ - Color? fillColor, - Color borderColor = const Color(0x00000000), - double borderStrokeWidth = 3.0, - bool isDotted = false, - String? label, - TextStyle labelStyle = const TextStyle(), - PolygonLabelPlacement labelPlacement = PolygonLabelPlacement.polylabel, - }) => - PolygonLayer( - polygons: [ - Polygon( - points: outline, - color: fillColor, - borderColor: borderColor, - borderStrokeWidth: borderStrokeWidth, - pattern: isDotted - ? const StrokePattern.dotted() - : const StrokePattern.solid(), - label: label, - labelStyle: labelStyle, - labelPlacement: labelPlacement, - ), - ], - ); - @override List toOutline() => outline; diff --git a/lib/src/regions/line.dart b/lib/src/regions/line.dart index e498ebe4..ec727c46 100644 --- a/lib/src/regions/line.dart +++ b/lib/src/regions/line.dart @@ -89,75 +89,6 @@ class LineRegion extends BaseRegion { crs: crs, ); - /// **Deprecated.** Instead obtain the outline/line/points using other methods, - /// and render the layer manually. This method is being removed to reduce - /// dependency on flutter_map, and allow full usage of flutter_map - /// functionality without it needing to be semi-implemented here. This feature - /// was deprecated after v9.1.0, and will be removed in the next breaking/major - /// release. - /// - /// If `prettyPaint` was `true`, render a `Polyline` based on [line] and - /// [radius]. Otherwise, render multiple `Polygons` based on the result of - /// `toOutlines(1)`. - @Deprecated( - 'Instead obtain the outline/line/points using other methods, and render the ' - 'layer manually. ' - 'This method is being removed to reduce dependency on flutter_map, and allow ' - 'full usage of flutter_map functionality without it needing to be ' - 'semi-implemented here. ' - 'This feature was deprecated after v9.1.0, and will be removed in the next ' - 'breaking/major release.', - ) - @override - Widget toDrawable({ - Color? fillColor, - Color? borderColor, - double borderStrokeWidth = 3, - bool isDotted = false, - bool prettyPaint = true, - StrokeCap strokeCap = StrokeCap.round, - StrokeJoin strokeJoin = StrokeJoin.round, - List? gradientColors, - List? colorsStop, - }) => - prettyPaint - ? PolylineLayer( - polylines: [ - Polyline( - points: line, - strokeWidth: radius, - useStrokeWidthInMeter: true, - color: fillColor ?? const Color(0x00000000), - borderColor: borderColor ?? const Color(0x00000000), - borderStrokeWidth: borderStrokeWidth, - pattern: isDotted - ? const StrokePattern.dotted() - : const StrokePattern.solid(), - gradientColors: gradientColors, - colorsStop: colorsStop, - strokeCap: strokeCap, - strokeJoin: strokeJoin, - ), - ], - ) - : PolygonLayer( - polygons: toOutlines(1) - .map( - (rect) => Polygon( - points: rect, - color: fillColor, - borderColor: borderColor ?? const Color(0x00000000), - borderStrokeWidth: borderStrokeWidth, - pattern: isDotted - ? const StrokePattern.dotted() - : const StrokePattern.solid(), - strokeCap: strokeCap, - strokeJoin: strokeJoin, - ), - ) - .toList(), - ); - /// Flattens the result of [toOutlines] - its documentation is quoted below /// /// > Generate the list of rectangle segments formed from the locus of this diff --git a/lib/src/regions/rectangle.dart b/lib/src/regions/rectangle.dart index 957cff6f..901b5ecb 100644 --- a/lib/src/regions/rectangle.dart +++ b/lib/src/regions/rectangle.dart @@ -40,48 +40,6 @@ class RectangleRegion extends BaseRegion { crs: crs, ); - /// **Deprecated.** Instead obtain the outline/line/points using other methods, - /// and render the layer manually. This method is being removed to reduce - /// dependency on flutter_map, and allow full usage of flutter_map - /// functionality without it needing to be semi-implemented here. This feature - /// was deprecated after v9.1.0, and will be removed in the next breaking/major - /// release. - @Deprecated( - 'Instead obtain the outline/line/points using other methods, and render the ' - 'layer manually. ' - 'This method is being removed to reduce dependency on flutter_map, and allow ' - 'full usage of flutter_map functionality without it needing to be ' - 'semi-implemented here. ' - 'This feature was deprecated after v9.1.0, and will be removed in the next ' - 'breaking/major release.', - ) - @override - PolygonLayer toDrawable({ - Color? fillColor, - Color borderColor = const Color(0x00000000), - double borderStrokeWidth = 3.0, - bool isDotted = false, - String? label, - TextStyle labelStyle = const TextStyle(), - PolygonLabelPlacement labelPlacement = PolygonLabelPlacement.polylabel, - }) => - PolygonLayer( - polygons: [ - Polygon( - color: fillColor, - borderColor: borderColor, - borderStrokeWidth: borderStrokeWidth, - pattern: isDotted - ? const StrokePattern.dotted() - : const StrokePattern.solid(), - label: label, - labelStyle: labelStyle, - labelPlacement: labelPlacement, - points: toOutline(), - ), - ], - ); - @override List toOutline() => [bounds.northEast, bounds.southEast, bounds.southWest, bounds.northWest]; diff --git a/lib/src/root/root.dart b/lib/src/root/root.dart index e01623d2..d214344d 100644 --- a/lib/src/root/root.dart +++ b/lib/src/root/root.dart @@ -3,21 +3,6 @@ part of '../../flutter_map_tile_caching.dart'; -/// Equivalent to [FMTCRoot], provided to ease migration only -/// -/// The name refers to earlier versions of this library where the filesystem -/// was used for storage, instead of a database. -/// -/// This deprecation typedef will be removed in a future release: migrate to -/// [FMTCRoot]. -@Deprecated( - ''' -Migrate to `FMTCRoot`. This deprecation typedef is provided to ease migration -only. It will be removed in a future version. -''', -) -typedef RootDirectory = FMTCRoot; - /// Provides access to statistics, recovery, migration (and the import /// functionality) on the intitialised root. /// diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index 31c745f3..b98db404 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -5,21 +5,6 @@ part of '../../flutter_map_tile_caching.dart'; -/// Equivalent to [FMTCStore], provided to ease migration only -/// -/// The name refers to earlier versions of this library where the filesystem -/// was used for storage, instead of a database. -/// -/// This deprecation typedef will be removed in a future release: migrate to -/// [FMTCStore]. -@Deprecated( - ''' -Migrate to `FMTCStore`. This deprecation typedef is provided to ease migration -only. It will be removed in a future version. -''', -) -typedef StoreDirectory = FMTCStore; - /// {@template fmtc.fmtcStore} /// Provides access to management, statistics, metadata, bulk download, /// the tile provider (and the export functionality) on the store named From 6778e8a9a4d71fbd7a946fce6f51a34e6033a7de Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 28 May 2024 13:45:07 +0100 Subject: [PATCH 04/97] Updated versioning and CHANGELOG Added internal `_DownloadManagerControlCmd` enum to better describe commands sent to/from the download manager thread (instead of numbers) Refactored download recovery startup to avoid waiting for manager thread to send already known information --- CHANGELOG.md | 16 ++++++++++++++++ lib/flutter_map_tile_caching.dart | 1 + lib/src/bulk_download/control_cmds.dart | 10 ++++++++++ lib/src/bulk_download/instance.dart | 6 ++++-- lib/src/bulk_download/manager.dart | 16 ++++++++++------ lib/src/store/download.dart | 17 ++++++++++------- pubspec.yaml | 2 +- 7 files changed, 52 insertions(+), 16 deletions(-) create mode 100644 lib/src/bulk_download/control_cmds.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fe8967d9..efb2d88a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,22 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons # Changelog +## [10.0.0] - 2024/XX/XX + +This update builds on v9 to fully embrace the new many-to-many relationship between tiles and stores, which allows for more flexibility when constructing the `FMTCTileProvider`. +This allows a new paradigm to be used: stores may now be treated as bulk downloaded regions, and all the regions/stores can be used at once - no more switching between them. This allows huge amounts of flexibility and a better UX in a complex application. + +Additionally, vector tiles are now supported in theory, as the internal caching/retrieval logic of the specialised `ImageProvider` has been exposed, although it is out of scope to fully implement support for it. + +* Improvements to the browse caching logic and customizability + * Added support for using multiple stores simultaneously in the `FMTCTileProvider`, and exposed constructor directly + * Added more `CacheBehavior` options + * Added toggle for hit/miss stat recording, to improve performance where these statistics are never read + * Replaced `FMTCTileProviderSettings.maxStoreLength` with a `maxLength` property on each store individually + * Refactored and exposed tile provider logic into seperate `getBytes` method +* Removed deprecated remnants from v9.* +* Other generic improvements + ## [9.1.0] - 2024/05/27 * Upgraded to flutter_map v7 to support Flutter 3.22 (also upgraded other dependencies) diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 6bf48620..d0a9f93d 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -41,6 +41,7 @@ import 'src/providers/browsing_errors.dart'; export 'src/backend/export_external.dart'; export 'src/providers/browsing_errors.dart'; +part 'src/bulk_download/control_cmds.dart'; part 'src/bulk_download/download_progress.dart'; part 'src/bulk_download/manager.dart'; part 'src/bulk_download/thread.dart'; diff --git a/lib/src/bulk_download/control_cmds.dart b/lib/src/bulk_download/control_cmds.dart new file mode 100644 index 00000000..713e95a8 --- /dev/null +++ b/lib/src/bulk_download/control_cmds.dart @@ -0,0 +1,10 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../flutter_map_tile_caching.dart'; + +enum _DownloadManagerControlCmd { + cancel, + resume, + pause, +} diff --git a/lib/src/bulk_download/instance.dart b/lib/src/bulk_download/instance.dart index ea8111af..ba429079 100644 --- a/lib/src/bulk_download/instance.dart +++ b/lib/src/bulk_download/instance.dart @@ -15,9 +15,11 @@ class DownloadInstance { final Object id; - Future Function()? requestCancel; - bool isPaused = false; + + // The following callbacks are defined by the `StoreDownload.startForeground` + // method, when a download is started, and are tied to that download operation + Future Function()? requestCancel; Future Function()? requestPause; void Function()? requestResume; diff --git a/lib/src/bulk_download/manager.dart b/lib/src/bulk_download/manager.dart index fb67a932..6aa7047e 100644 --- a/lib/src/bulk_download/manager.dart +++ b/lib/src/bulk_download/manager.dart @@ -133,21 +133,23 @@ Future _downloadManager( final cancelSignal = Completer(); var pauseResumeSignal = Completer()..complete(); rootReceivePort.listen( - (e) async { - if (e == null) { + (cmd) async { + if (cmd == _DownloadManagerControlCmd.cancel) { try { cancelSignal.complete(); // ignore: avoid_catching_errors, empty_catches } on StateError {} - } else if (e == 1) { + } else if (cmd == _DownloadManagerControlCmd.pause) { pauseResumeSignal = Completer(); threadPausedStates.setAll(0, generateThreadPausedStates()); await Future.wait(threadPausedStates.map((e) => e.future)); downloadDuration.stop(); - send(1); - } else if (e == 2) { + send(_DownloadManagerControlCmd.pause); + } else if (cmd == _DownloadManagerControlCmd.resume) { pauseResumeSignal.complete(); downloadDuration.start(); + } else { + throw UnimplementedError('Recieved unknown cmd: $cmd'); } }, ); @@ -181,7 +183,8 @@ Future _downloadManager( region: input.region, endTile: math.min(input.region.end ?? largestInt, maxTiles), ); - send(2); + // TODO: Remove once validated + // send(2); } // Duplicate the backend to make it safe to send through isolates @@ -277,6 +280,7 @@ Future _downloadManager( ); } + // TODO: Make updates batched to improve efficiency if (input.recoveryId case final recoveryId?) { input.backend.updateRecovery( id: recoveryId, diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 52c2257b..0383b3b2 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -175,6 +175,7 @@ class StoreDownload { final recoveryId = disableRecovery ? null : Object.hash(instanceId, DateTime.timestamp().millisecondsSinceEpoch); + if (!disableRecovery) FMTCRoot.recovery._downloadsOngoing.add(recoveryId!); // Start download thread final receivePort = ReceivePort(); @@ -212,7 +213,7 @@ class StoreDownload { } // Handle pause comms - if (evt == 1) { + if (evt == _DownloadManagerControlCmd.pause) { pauseCompleter?.complete(); continue; } @@ -221,25 +222,27 @@ class StoreDownload { if (evt == null) break; // Handle recovery system startup (unless disabled) - if (evt == 2) { + // TODO: Remove once validated + /*if (evt == 2) { FMTCRoot.recovery._downloadsOngoing.add(recoveryId!); continue; - } + }*/ // Setup control mechanisms (senders) if (evt is SendPort) { instance ..requestCancel = () { - evt.send(null); + evt.send(_DownloadManagerControlCmd.cancel); return cancelCompleter.future; } ..requestPause = () { - evt.send(1); + evt.send(_DownloadManagerControlCmd.pause); + // Completed by handler above return (pauseCompleter = Completer()).future ..then((_) => instance.isPaused = true); } ..requestResume = () { - evt.send(2); + evt.send(_DownloadManagerControlCmd.resume); instance.isPaused = false; }; continue; @@ -250,7 +253,7 @@ class StoreDownload { // Handle shutdown (both normal and cancellation) receivePort.close(); - if (recoveryId != null) await FMTCRoot.recovery.cancel(recoveryId); + if (!disableRecovery) await FMTCRoot.recovery.cancel(recoveryId!); DownloadInstance.unregister(instanceId); cancelCompleter.complete(); } diff --git a/pubspec.yaml b/pubspec.yaml index 6fbb7e16..8f80d672 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 10.0.0 +version: 10.0.0-dev.1 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues From 99826bd072e721279e1482ea17249425dfeabfa6 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 12 Jun 2024 19:16:10 +0100 Subject: [PATCH 05/97] Started update of example app Refactored `RootRecovery` singleton instance mangement to be class-internal --- example/.gitignore | 3 + example/analysis_options.yaml | 4 + example/lib/main.dart | 366 ++++++++++++++-- .../components/numerical_input_row.dart | 136 ------ .../components/options_pane.dart | 44 -- .../components/region_information.dart | 249 ----------- .../components/start_download_button.dart | 135 ------ .../components/store_selector.dart | 55 --- .../configure_download.dart | 160 ------- .../state/configure_download_provider.dart | 51 --- .../components/directory_selected.dart | 17 - .../export_import/components/export.dart | 84 ---- .../export_import/components/import.dart | 208 ---------- .../components/no_path_selected.dart | 17 - .../export_import/components/path_picker.dart | 149 ------- .../screens/export_import/export_import.dart | 236 ----------- example/lib/screens/main/main.dart | 156 ------- .../components/download_layout.dart | 268 ------------ .../components/main_statistics.dart | 181 -------- .../multi_linear_progress_indicator.dart | 88 ---- .../downloading/components/stat_display.dart | 33 -- .../downloading/components/stats_table.dart | 83 ---- .../main/pages/downloading/downloading.dart | 132 ------ .../state/downloading_provider.dart | 98 ----- .../map/components/bubble_arrow_painter.dart | 38 -- .../download_progress_indicator.dart | 99 ----- .../map/components/empty_tile_provider.dart | 11 - .../main/pages/map/components/map_view.dart | 104 ----- .../quit_tiles_preview_indicator.dart | 72 ---- .../components/side_indicator_painter.dart | 52 --- .../lib/screens/main/pages/map/map_page.dart | 48 --- .../recovery/components/empty_indicator.dart | 19 - .../pages/recovery/components/header.dart | 17 - .../recovery/components/recovery_list.dart | 85 ---- .../components/recovery_start_button.dart | 52 --- .../screens/main/pages/recovery/recovery.dart | 71 ---- .../components/side_panel/parent.dart | 62 --- .../region_selection/region_selection.dart | 301 -------------- .../stores/components/empty_indicator.dart | 19 - .../main/pages/stores/components/header.dart | 56 --- .../stores/components/root_stats_pane.dart | 104 ----- .../pages/stores/components/stat_display.dart | 34 -- .../pages/stores/components/store_tile.dart | 267 ------------ .../lib/screens/main/pages/stores/stores.dart | 123 ------ .../store_editor/components/header.dart | 101 ----- .../screens/store_editor/store_editor.dart | 262 ------------ example/lib/shared/misc/circular_buffer.dart | 61 --- .../lib/shared/state/general_provider.dart | 15 - .../config_panel/components/stores_list.dart | 211 ++++++++++ .../screens/home/config_panel/map_config.dart | 137 ++++++ .../wrappers/bottom_sheet/bottom_sheet.dart | 145 +++++++ .../components/scrollable_provider.dart | 22 + .../bottom_sheet/components/toolbar.dart | 54 +++ .../tabs/stores/components/tab_header.dart | 134 ++++++ .../bottom_sheet/tabs/stores/stores.dart | 69 ++++ .../wrappers/side_panel/side_panel.dart | 66 +++ example/lib/src/screens/home/home.dart | 191 +++++++++ .../home/map_view/bottom_sheet_wrapper.dart | 97 +++++ .../src/screens/home/map_view/map_view.dart | 390 ++++++++++++++++++ .../crosshairs.dart | 0 .../custom_polygon_snapping_indicator.dart | 0 .../region_shape.dart | 1 - .../additional_panes/additional_pane.dart | 0 .../adjust_zoom_lvls_pane.dart | 0 .../additional_panes/line_region_pane.dart | 0 .../additional_panes/slider_panel_base.dart | 0 .../side_panel/custom_slider_track_shape.dart | 0 .../side_panel/parent.dart | 73 ++++ .../side_panel/primary_pane.dart | 7 +- .../side_panel/region_shape_button.dart | 0 .../usage_instructions.dart | 13 +- .../home/map_view}/state/map_provider.dart | 4 +- .../state/region_selection_provider.dart | 13 +- .../initialisation_error.dart | 29 +- .../shared/components/build_attribution.dart | 0 ...ayed_frame_attached_dependent_builder.dart | 48 +++ .../shared/components/loading_indicator.dart | 0 .../lib/src/shared/misc/circular_buffer.dart | 59 +++ .../shared/misc/exts/interleave.dart | 0 .../shared/misc/exts/size_formatter.dart | 0 .../shared/misc/region_selection_method.dart | 0 .../{ => src}/shared/misc/region_type.dart | 0 .../src/shared/misc/shared_preferences.dart | 9 + .../src/shared/state/general_provider.dart | 32 ++ example/pubspec.yaml | 3 +- lib/src/root/recovery.dart | 8 +- lib/src/root/root.dart | 3 +- 87 files changed, 2124 insertions(+), 4720 deletions(-) delete mode 100644 example/lib/screens/configure_download/components/numerical_input_row.dart delete mode 100644 example/lib/screens/configure_download/components/options_pane.dart delete mode 100644 example/lib/screens/configure_download/components/region_information.dart delete mode 100644 example/lib/screens/configure_download/components/start_download_button.dart delete mode 100644 example/lib/screens/configure_download/components/store_selector.dart delete mode 100644 example/lib/screens/configure_download/configure_download.dart delete mode 100644 example/lib/screens/configure_download/state/configure_download_provider.dart delete mode 100644 example/lib/screens/export_import/components/directory_selected.dart delete mode 100644 example/lib/screens/export_import/components/export.dart delete mode 100644 example/lib/screens/export_import/components/import.dart delete mode 100644 example/lib/screens/export_import/components/no_path_selected.dart delete mode 100644 example/lib/screens/export_import/components/path_picker.dart delete mode 100644 example/lib/screens/export_import/export_import.dart delete mode 100644 example/lib/screens/main/main.dart delete mode 100644 example/lib/screens/main/pages/downloading/components/download_layout.dart delete mode 100644 example/lib/screens/main/pages/downloading/components/main_statistics.dart delete mode 100644 example/lib/screens/main/pages/downloading/components/multi_linear_progress_indicator.dart delete mode 100644 example/lib/screens/main/pages/downloading/components/stat_display.dart delete mode 100644 example/lib/screens/main/pages/downloading/components/stats_table.dart delete mode 100644 example/lib/screens/main/pages/downloading/downloading.dart delete mode 100644 example/lib/screens/main/pages/downloading/state/downloading_provider.dart delete mode 100644 example/lib/screens/main/pages/map/components/bubble_arrow_painter.dart delete mode 100644 example/lib/screens/main/pages/map/components/download_progress_indicator.dart delete mode 100644 example/lib/screens/main/pages/map/components/empty_tile_provider.dart delete mode 100644 example/lib/screens/main/pages/map/components/map_view.dart delete mode 100644 example/lib/screens/main/pages/map/components/quit_tiles_preview_indicator.dart delete mode 100644 example/lib/screens/main/pages/map/components/side_indicator_painter.dart delete mode 100644 example/lib/screens/main/pages/map/map_page.dart delete mode 100644 example/lib/screens/main/pages/recovery/components/empty_indicator.dart delete mode 100644 example/lib/screens/main/pages/recovery/components/header.dart delete mode 100644 example/lib/screens/main/pages/recovery/components/recovery_list.dart delete mode 100644 example/lib/screens/main/pages/recovery/components/recovery_start_button.dart delete mode 100644 example/lib/screens/main/pages/recovery/recovery.dart delete mode 100644 example/lib/screens/main/pages/region_selection/components/side_panel/parent.dart delete mode 100644 example/lib/screens/main/pages/region_selection/region_selection.dart delete mode 100644 example/lib/screens/main/pages/stores/components/empty_indicator.dart delete mode 100644 example/lib/screens/main/pages/stores/components/header.dart delete mode 100644 example/lib/screens/main/pages/stores/components/root_stats_pane.dart delete mode 100644 example/lib/screens/main/pages/stores/components/stat_display.dart delete mode 100644 example/lib/screens/main/pages/stores/components/store_tile.dart delete mode 100644 example/lib/screens/main/pages/stores/stores.dart delete mode 100644 example/lib/screens/store_editor/components/header.dart delete mode 100644 example/lib/screens/store_editor/store_editor.dart delete mode 100644 example/lib/shared/misc/circular_buffer.dart delete mode 100644 example/lib/shared/state/general_provider.dart create mode 100644 example/lib/src/screens/home/config_panel/components/stores_list.dart create mode 100644 example/lib/src/screens/home/config_panel/map_config.dart create mode 100644 example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/bottom_sheet.dart create mode 100644 example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/scrollable_provider.dart create mode 100644 example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/toolbar.dart create mode 100644 example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/components/tab_header.dart create mode 100644 example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart create mode 100644 example/lib/src/screens/home/config_panel/wrappers/side_panel/side_panel.dart create mode 100644 example/lib/src/screens/home/home.dart create mode 100644 example/lib/src/screens/home/map_view/bottom_sheet_wrapper.dart create mode 100644 example/lib/src/screens/home/map_view/map_view.dart rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/crosshairs.dart (100%) rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/custom_polygon_snapping_indicator.dart (100%) rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/region_shape.dart (98%) rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/side_panel/additional_panes/additional_pane.dart (100%) rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/side_panel/additional_panes/adjust_zoom_lvls_pane.dart (100%) rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/side_panel/additional_panes/line_region_pane.dart (100%) rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/side_panel/additional_panes/slider_panel_base.dart (100%) rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/side_panel/custom_slider_track_shape.dart (100%) create mode 100644 example/lib/src/screens/home/map_view/region_selection_components/side_panel/parent.dart rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/side_panel/primary_pane.dart (97%) rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/side_panel/region_shape_button.dart (100%) rename example/lib/{screens/main/pages/region_selection/components => src/screens/home/map_view/region_selection_components}/usage_instructions.dart (90%) rename example/lib/{screens/main/pages/map => src/screens/home/map_view}/state/map_provider.dart (94%) rename example/lib/{screens/main/pages/region_selection => src/screens/home/map_view}/state/region_selection_provider.dart (96%) rename example/lib/{ => src}/screens/initialisation_error/initialisation_error.dart (83%) rename example/lib/{ => src}/shared/components/build_attribution.dart (100%) create mode 100644 example/lib/src/shared/components/delayed_frame_attached_dependent_builder.dart rename example/lib/{ => src}/shared/components/loading_indicator.dart (100%) create mode 100644 example/lib/src/shared/misc/circular_buffer.dart rename example/lib/{ => src}/shared/misc/exts/interleave.dart (100%) rename example/lib/{ => src}/shared/misc/exts/size_formatter.dart (100%) rename example/lib/{ => src}/shared/misc/region_selection_method.dart (100%) rename example/lib/{ => src}/shared/misc/region_type.dart (100%) create mode 100644 example/lib/src/shared/misc/shared_preferences.dart create mode 100644 example/lib/src/shared/state/general_provider.dart diff --git a/example/.gitignore b/example/.gitignore index 0fa6b675..2a04e326 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1,3 +1,6 @@ +# Custom +old_lib/ + # Miscellaneous *.class *.log diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index cb1978b3..011223fb 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1,5 +1,9 @@ include: ../analysis_options.yaml +analyzer: + exclude: + - old_lib/ + linter: rules: public_member_api_docs: false diff --git a/example/lib/main.dart b/example/lib/main.dart index 2589a9f7..8cf56ed7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,25 +1,271 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -import 'screens/configure_download/state/configure_download_provider.dart'; -import 'screens/initialisation_error/initialisation_error.dart'; -import 'screens/main/main.dart'; -import 'screens/main/pages/downloading/state/downloading_provider.dart'; -import 'screens/main/pages/map/state/map_provider.dart'; -import 'screens/main/pages/region_selection/state/region_selection_provider.dart'; -import 'shared/state/general_provider.dart'; +import 'src/screens/home/home.dart'; +import 'src/screens/home/map_view/state/region_selection_provider.dart'; +import 'src/screens/initialisation_error/initialisation_error.dart'; +import 'src/shared/misc/shared_preferences.dart'; +import 'src/shared/state/general_provider.dart'; + +/*void main() { + runApp(RootWidget()); +} + +class RootWidget extends StatelessWidget { + const RootWidget({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: HomeWidget(), + ); + } +} + +class HomeWidget extends StatefulWidget { + const HomeWidget({super.key}); + + @override + State createState() => _HomeWidgetState(); +} + +class _HomeWidgetState extends State { + final bottomSheetOuterController = DraggableScrollableController(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SizedBox.expand( + child: ColoredBox( + color: Colors.red, + child: Center( + child: ElevatedButton( + onPressed: () { + print(bottomSheetOuterController.isAttached); + bottomSheetOuterController.animateTo( + 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + ); + }, + child: Text('expand'), + ), + ), + ), + ), + bottomSheet: DraggableScrollableSheet( + initialChildSize: 0.3, + expand: false, + controller: bottomSheetOuterController, + builder: (context, innerController) => ColoredBox( + color: Colors.blue, + child: SizedBox.expand( + child: Center( + child: ElevatedButton( + onPressed: () { + print(bottomSheetOuterController.isAttached); + bottomSheetOuterController.animateTo( + 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + ); + }, + child: Text('expand'), + ), + ), + ), + ), + ), + /*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', + ), + ], + onDestinationSelected: (i) { + if (i == 1) { + bottomSheetOuterController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + ); + } else { + bottomSheetOuterController.animateTo( + 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + ); + } + }, + ),*/ + ); + } +} + +class CustomBottomSheet extends StatefulWidget { + const CustomBottomSheet({ + super.key, + required this.controller, + }); + + final DraggableScrollableController controller; + + @override + State createState() => _CustomBottomSheetState(); +} + +class _CustomBottomSheetState extends State { + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.3, + minChildSize: 0, + snap: true, + expand: false, + snapSizes: const [0.3], + controller: widget.controller, + builder: (context, innerController) => ColoredBox( + color: Colors.blue, + child: SizedBox.expand( + child: Center( + child: ElevatedButton( + onPressed: () { + print(widget.controller.isAttached); + widget.controller.animateTo( + 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + ); + }, + child: Text('expand'), + ), + ), + ), + ), + /* DelayedControllerAttachmentBuilder( + listenable: widget.controller, + builder: (context, child) { + double radius = 18; + double calcHeight = 0; + + if (widget.controller.isAttached) { + final maxHeight = widget.controller.sizeToPixels(1); + + final oldValue = widget.controller.pixels; + final oldMax = maxHeight; + final oldMin = maxHeight - radius; + const newMax = 0.0; + final newMin = radius; + + radius = ((((oldValue - oldMin) * (newMax - newMin)) / + (oldMax - oldMin)) + + newMin) + .clamp(0, radius); + + calcHeight = screenTopPadding - + constraints.maxHeight + + widget.controller.pixels; + } + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(radius), + topRight: Radius.circular(radius), + ), + child: Column( + children: [ + DelayedControllerAttachmentBuilder( + listenable: innerController, + builder: (context, _) => SizedBox( + height: calcHeight.clamp(0, screenTopPadding), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + color: innerController.hasClients && + innerController.offset != 0 + ? Theme.of(context) + .colorScheme + .surfaceContainerLowest + : Theme.of(context).colorScheme.surfaceContainerLow, + ), + ), + ), + Expanded( + child: ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: child, + ), + ), + ], + ), + ); + }, + child: Stack( + children: [ + BottomSheetScrollableProvider( + innerScrollController: innerController, + child: widget.child, + ), + IgnorePointer( + child: DelayedControllerAttachmentBuilder( + listenable: widget.controller, + builder: (context, _) { + if (!widget.controller.isAttached) { + return const SizedBox.shrink(); + } + + final calcHeight = BottomSheet.topPadding - + (screenTopPadding - + constraints.maxHeight + + widget.controller.pixels); + + return SizedBox( + height: calcHeight.clamp(0, BottomSheet.topPadding), + width: constraints.maxWidth, + child: Semantics( + label: MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + container: true, + child: Center( + child: Container( + height: 4, + width: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withOpacity(0.4), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ),*/ + ); + } +}*/ void main() async { WidgetsFlutterBinding.ensureInitialized(); - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - ), - ); + + sharedPrefs = await SharedPreferences.getInstance(); Object? initErr; try { @@ -28,6 +274,8 @@ void main() async { initErr = err; } + await const FMTCStore('Test Store').manage.create(); + runApp(_AppContainer(initialisationError: initErr)); } @@ -38,18 +286,72 @@ class _AppContainer extends StatelessWidget { final Object? initialisationError; + static final _routes = Function({ + required Widget Function( + BuildContext, + Animation, + Animation, + ) pageBuilder, + required RouteSettings settings, + })? custom, + Widget Function(BuildContext) std, + })>{ + HomeScreen.route: ( + std: (BuildContext context) => const HomeScreen(), + custom: null, + ), + /*ManageOfflineScreen.route: ( + std: (BuildContext context) => ManageOfflineScreen(), + custom: null, + ), + RegionSelectionScreen.route: ( + std: (BuildContext context) => const RegionSelectionScreen(), + custom: null, + ), + ProfileScreen.route: ( + std: (BuildContext context) => const ProfileScreen(), + custom: ({ + required Widget Function( + BuildContext, + Animation, + Animation, + ) pageBuilder, + required RouteSettings settings, + }) => + PageRouteBuilder( + pageBuilder: pageBuilder, + settings: settings, + transitionsBuilder: (context, animation, _, child) { + const begin = Offset(0, 1); + const end = Offset.zero; + const curve = Curves.ease; + + final tween = + Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); + + return SlideTransition( + position: animation.drive(tween), + child: child, + ); + }, + ), + ),*/ + }; + @override Widget build(BuildContext context) { final themeData = ThemeData( - brightness: Brightness.dark, + brightness: Brightness.light, useMaterial3: true, - textTheme: GoogleFonts.ubuntuTextTheme(ThemeData.dark().textTheme), - colorSchemeSeed: Colors.red, + textTheme: GoogleFonts.ubuntuTextTheme(ThemeData.light().textTheme), + colorSchemeSeed: Colors.orange, switchTheme: SwitchThemeData( thumbIcon: WidgetStateProperty.resolveWith( - (states) => Icon( - states.contains(WidgetState.selected) ? Icons.check : Icons.close, - ), + (states) => states.contains(WidgetState.selected) + ? const Icon(Icons.check) + : null, ), ), ); @@ -67,14 +369,14 @@ class _AppContainer extends StatelessWidget { ChangeNotifierProvider( create: (_) => GeneralProvider(), ), - ChangeNotifierProvider( + /*ChangeNotifierProvider( create: (_) => MapProvider(), lazy: true, - ), + ),*/ ChangeNotifierProvider( create: (_) => RegionSelectionProvider(), lazy: true, - ), + ), /* ChangeNotifierProvider( create: (_) => ConfigureDownloadProvider(), lazy: true, @@ -82,12 +384,26 @@ class _AppContainer extends StatelessWidget { ChangeNotifierProvider( create: (_) => DownloadingProvider(), lazy: true, - ), + ),*/ ], child: MaterialApp( title: 'FMTC Demo', + restorationScopeId: 'FMTC Demo', theme: themeData, - home: const MainScreen(), + initialRoute: HomeScreen.route, + onGenerateRoute: (settings) { + final route = _routes[settings.name]!; + if (route.custom != null) { + return route.custom!( + pageBuilder: (context, _, __) => route.std(context), + settings: settings, + ); + } + return MaterialPageRoute( + builder: route.std, + settings: settings, + ); + }, ), ); } diff --git a/example/lib/screens/configure_download/components/numerical_input_row.dart b/example/lib/screens/configure_download/components/numerical_input_row.dart deleted file mode 100644 index a3fbf60d..00000000 --- a/example/lib/screens/configure_download/components/numerical_input_row.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -import '../state/configure_download_provider.dart'; - -class NumericalInputRow extends StatefulWidget { - const NumericalInputRow({ - super.key, - required this.label, - required this.suffixText, - required this.value, - required this.min, - required this.max, - this.maxEligibleTilesPreview, - required this.onChanged, - }); - - final String label; - final String suffixText; - final int Function(ConfigureDownloadProvider provider) value; - final int min; - final int? max; - final int? maxEligibleTilesPreview; - final void Function(ConfigureDownloadProvider provider, int value) onChanged; - - @override - State createState() => _NumericalInputRowState(); -} - -class _NumericalInputRowState extends State { - TextEditingController? tec; - - @override - Widget build(BuildContext context) => - Selector( - selector: (context, provider) => widget.value(provider), - builder: (context, currentValue, _) { - tec ??= TextEditingController(text: currentValue.toString()); - - return Row( - children: [ - Text(widget.label), - const Spacer(), - if (widget.maxEligibleTilesPreview != null) ...[ - IconButton( - icon: const Icon(Icons.visibility), - disabledColor: Colors.green, - tooltip: currentValue > widget.maxEligibleTilesPreview! - ? 'Tap to enable following download live' - : 'Eligible to follow download live', - onPressed: currentValue > widget.maxEligibleTilesPreview! - ? () { - widget.onChanged( - context.read(), - widget.maxEligibleTilesPreview!, - ); - tec!.text = widget.maxEligibleTilesPreview.toString(); - } - : null, - ), - const SizedBox(width: 8), - ], - if (widget.max != null) ...[ - Tooltip( - message: currentValue == widget.max - ? 'Limited in the example app' - : '', - child: Icon( - Icons.lock, - color: currentValue == widget.max - ? Colors.amber - : Colors.white.withOpacity(0.2), - ), - ), - const SizedBox(width: 16), - ], - IntrinsicWidth( - child: TextFormField( - controller: tec, - textAlign: TextAlign.end, - keyboardType: TextInputType.number, - decoration: InputDecoration( - isDense: true, - counterText: '', - suffixText: ' ${widget.suffixText}', - ), - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - _NumericalRangeFormatter( - min: widget.min, - max: widget.max ?? 9223372036854775807, - ), - ], - onChanged: (newVal) => widget.onChanged( - context.read(), - int.tryParse(newVal) ?? currentValue, - ), - ), - ), - ], - ); - }, - ); -} - -class _NumericalRangeFormatter extends TextInputFormatter { - const _NumericalRangeFormatter({required this.min, required this.max}); - final int min; - final int max; - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - if (newValue.text.isEmpty) return newValue; - - final int parsed = int.parse(newValue.text); - - if (parsed < min) { - return TextEditingValue.empty.copyWith( - text: min.toString(), - selection: TextSelection.collapsed(offset: min.toString().length), - ); - } - if (parsed > max) { - return TextEditingValue.empty.copyWith( - text: max.toString(), - selection: TextSelection.collapsed(offset: max.toString().length), - ); - } - - return newValue; - } -} diff --git a/example/lib/screens/configure_download/components/options_pane.dart b/example/lib/screens/configure_download/components/options_pane.dart deleted file mode 100644 index 1993455b..00000000 --- a/example/lib/screens/configure_download/components/options_pane.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../shared/misc/exts/interleave.dart'; - -class OptionsPane extends StatelessWidget { - const OptionsPane({ - super.key, - required this.label, - required this.children, - this.interPadding = 8, - }); - - final String label; - final Iterable children; - final double interPadding; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 14), - child: Text(label), - ), - const SizedBox.square(dimension: 4), - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: children.singleOrNull ?? - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: children - .interleave(SizedBox.square(dimension: interPadding)) - .toList(), - ), - ), - ), - ], - ); -} diff --git a/example/lib/screens/configure_download/components/region_information.dart b/example/lib/screens/configure_download/components/region_information.dart deleted file mode 100644 index b661e49e..00000000 --- a/example/lib/screens/configure_download/components/region_information.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:dart_earcut/dart_earcut.dart'; -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:latlong2/latlong.dart'; - -class RegionInformation extends StatefulWidget { - const RegionInformation({ - super.key, - required this.region, - required this.minZoom, - required this.maxZoom, - required this.startTile, - required this.endTile, - }); - - final BaseRegion region; - final int minZoom; - final int maxZoom; - final int startTile; - final int? endTile; - - @override - State createState() => _RegionInformationState(); -} - -class _RegionInformationState extends State { - final distance = const Distance(roundResult: false).distance; - - late Future numOfTiles; - - @override - void initState() { - super.initState(); - numOfTiles = const FMTCStore('').download.check( - widget.region.toDownloadable( - minZoom: widget.minZoom, - maxZoom: widget.maxZoom, - options: TileLayer(), - ), - ); - } - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...widget.region.when( - rectangle: (rectangle) => [ - const Text('TOTAL AREA'), - Text( - '${(distance(rectangle.bounds.northWest, rectangle.bounds.northEast) * distance(rectangle.bounds.northEast, rectangle.bounds.southEast) / 1000000).toStringAsFixed(3)} km²', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('APPROX. NORTH WEST'), - Text( - '${rectangle.bounds.northWest.latitude.toStringAsFixed(3)}, ${rectangle.bounds.northWest.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('APPROX. SOUTH EAST'), - Text( - '${rectangle.bounds.southEast.latitude.toStringAsFixed(3)}, ${rectangle.bounds.southEast.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ], - circle: (circle) => [ - const Text('TOTAL AREA'), - Text( - '${(pi * pow(circle.radius, 2)).toStringAsFixed(3)} km²', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('RADIUS'), - Text( - '${circle.radius.toStringAsFixed(2)} km', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('APPROX. CENTER'), - Text( - '${circle.center.latitude.toStringAsFixed(3)}, ${circle.center.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ], - line: (line) { - double totalDistance = 0; - - for (int i = 0; i < line.line.length - 1; i++) { - totalDistance += - distance(line.line[i], line.line[i + 1]); - } - - return [ - const Text('LINE LENGTH'), - Text( - '${(totalDistance / 1000).toStringAsFixed(3)} km', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('FIRST COORD'), - Text( - '${line.line[0].latitude.toStringAsFixed(3)}, ${line.line[0].longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('LAST COORD'), - Text( - '${line.line.last.latitude.toStringAsFixed(3)}, ${line.line.last.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ]; - }, - customPolygon: (customPolygon) { - double area = 0; - - for (final triangle in Earcut.triangulateFromPoints( - customPolygon.outline - .map(const Epsg3857().projection.project), - ).map(customPolygon.outline.elementAt).slices(3)) { - final a = distance(triangle[0], triangle[1]); - final b = distance(triangle[1], triangle[2]); - final c = distance(triangle[2], triangle[0]); - - area += 0.25 * - sqrt( - 4 * a * a * b * b - pow(a * a + b * b - c * c, 2), - ); - } - - return [ - const Text('TOTAL AREA'), - Text( - '${(area / 1000000).toStringAsFixed(3)} km²', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ]; - }, - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const Text('ZOOM LEVELS'), - Text( - '${widget.minZoom} - ${widget.maxZoom}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('TOTAL TILES'), - FutureBuilder( - future: numOfTiles, - builder: (context, snapshot) => snapshot.data == null - ? Padding( - padding: const EdgeInsets.only(top: 4), - child: SizedBox( - height: 36, - width: 36, - child: Center( - child: SizedBox( - height: 28, - width: 28, - child: CircularProgressIndicator( - color: - Theme.of(context).colorScheme.secondary, - ), - ), - ), - ), - ) - : Text( - NumberFormat('###,###').format(snapshot.data), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ), - const SizedBox(height: 10), - const Text('TILES RANGE'), - if (widget.startTile == 1 && widget.endTile == null) - const Text( - '*', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ) - else - Text( - '${NumberFormat('###,###').format(widget.startTile)} - ${widget.endTile != null ? NumberFormat('###,###').format(widget.endTile) : '*'}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ], - ), - ], - ), - ], - ); -} diff --git a/example/lib/screens/configure_download/components/start_download_button.dart b/example/lib/screens/configure_download/components/start_download_button.dart deleted file mode 100644 index 10c7da60..00000000 --- a/example/lib/screens/configure_download/components/start_download_button.dart +++ /dev/null @@ -1,135 +0,0 @@ -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:provider/provider.dart'; - -import '../../main/pages/downloading/state/downloading_provider.dart'; -import '../../main/pages/region_selection/state/region_selection_provider.dart'; -import '../state/configure_download_provider.dart'; - -class StartDownloadButton extends StatelessWidget { - const StartDownloadButton({ - super.key, - required this.region, - required this.minZoom, - required this.maxZoom, - required this.startTile, - required this.endTile, - }); - - final BaseRegion region; - final int minZoom; - final int maxZoom; - final int startTile; - final int? endTile; - - @override - Widget build(BuildContext context) => - Selector( - selector: (context, provider) => provider.isReady, - builder: (context, isReady, _) => - Selector( - selector: (context, provider) => provider.selectedStore, - builder: (context, selectedStore, child) => IgnorePointer( - ignoring: selectedStore == null, - child: AnimatedOpacity( - opacity: selectedStore == null ? 0 : 1, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: child, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - AnimatedScale( - scale: isReady ? 1 : 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInCubic, - alignment: Alignment.bottomRight, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurface, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - bottomLeft: Radius.circular(12), - ), - ), - margin: const EdgeInsets.only(right: 12, left: 32), - padding: const EdgeInsets.all(12), - constraints: const BoxConstraints(maxWidth: 500), - child: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - "You must abide by your tile server's Terms of Service when bulk downloading. 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.", - textAlign: TextAlign.end, - style: TextStyle(color: Colors.black), - ), - SizedBox(height: 8), - Icon(Icons.report, color: Colors.red, size: 32), - ], - ), - ), - ), - const SizedBox(height: 16), - FloatingActionButton.extended( - onPressed: () async { - final configureDownloadProvider = - context.read(); - - if (!isReady) { - configureDownloadProvider.isReady = true; - return; - } - - final regionSelectionProvider = - context.read(); - final downloadingProvider = - context.read(); - - final navigator = Navigator.of(context); - - final metadata = await regionSelectionProvider - .selectedStore!.metadata.read; - - downloadingProvider.setDownloadProgress( - regionSelectionProvider.selectedStore!.download - .startForeground( - region: region.toDownloadable( - minZoom: minZoom, - maxZoom: maxZoom, - start: startTile, - end: endTile, - options: TileLayer( - urlTemplate: metadata['sourceURL'], - userAgentPackageName: - 'dev.jaffaketchup.fmtc.demo', - ), - ), - parallelThreads: - configureDownloadProvider.parallelThreads, - maxBufferLength: - configureDownloadProvider.maxBufferLength, - skipExistingTiles: - configureDownloadProvider.skipExistingTiles, - skipSeaTiles: configureDownloadProvider.skipSeaTiles, - rateLimit: configureDownloadProvider.rateLimit, - ) - .asBroadcastStream(), - ); - configureDownloadProvider.isReady = false; - - navigator.pop(); - }, - label: const Text('Start Download'), - icon: Icon(isReady ? Icons.save : Icons.arrow_forward), - ), - ], - ), - ), - ); -} diff --git a/example/lib/screens/configure_download/components/store_selector.dart b/example/lib/screens/configure_download/components/store_selector.dart deleted file mode 100644 index ba28610f..00000000 --- a/example/lib/screens/configure_download/components/store_selector.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../../shared/state/general_provider.dart'; -import '../../main/pages/region_selection/state/region_selection_provider.dart'; - -class StoreSelector extends StatefulWidget { - const StoreSelector({super.key}); - - @override - State createState() => _StoreSelectorState(); -} - -class _StoreSelectorState extends State { - @override - Widget build(BuildContext context) => Row( - children: [ - const Text('Store'), - const Spacer(), - IntrinsicWidth( - child: Consumer2( - builder: (context, downloadProvider, generalProvider, _) => - FutureBuilder>( - future: FMTCRoot.stats.storesAvailable, - builder: (context, snapshot) => DropdownButton( - items: snapshot.data - ?.map( - (e) => DropdownMenuItem( - value: e, - child: Text(e.storeName), - ), - ) - .toList(), - onChanged: (store) => - downloadProvider.setSelectedStore(store), - value: downloadProvider.selectedStore ?? - (generalProvider.currentStore == null - ? null - : FMTCStore(generalProvider.currentStore!)), - hint: Text( - snapshot.data == null - ? 'Loading...' - : snapshot.data!.isEmpty - ? 'None Available' - : 'None Selected', - ), - padding: const EdgeInsets.only(left: 12), - ), - ), - ), - ), - ], - ); -} diff --git a/example/lib/screens/configure_download/configure_download.dart b/example/lib/screens/configure_download/configure_download.dart deleted file mode 100644 index 7ed25b95..00000000 --- a/example/lib/screens/configure_download/configure_download.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../shared/misc/exts/interleave.dart'; -import 'components/numerical_input_row.dart'; -import 'components/options_pane.dart'; -import 'components/region_information.dart'; -import 'components/start_download_button.dart'; -import 'components/store_selector.dart'; -import 'state/configure_download_provider.dart'; - -class ConfigureDownloadPopup extends StatelessWidget { - const ConfigureDownloadPopup({ - super.key, - required this.region, - required this.minZoom, - required this.maxZoom, - required this.startTile, - required this.endTile, - }); - - final BaseRegion region; - final int minZoom; - final int maxZoom; - final int startTile; - final int? endTile; - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Configure Bulk Download')), - floatingActionButton: StartDownloadButton( - region: region, - minZoom: minZoom, - maxZoom: maxZoom, - startTile: startTile, - endTile: endTile, - ), - body: Stack( - fit: StackFit.expand, - children: [ - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox.shrink(), - RegionInformation( - region: region, - minZoom: minZoom, - maxZoom: maxZoom, - startTile: startTile, - endTile: endTile, - ), - const Divider(thickness: 2, height: 8), - const OptionsPane( - label: 'STORE DIRECTORY', - children: [StoreSelector()], - ), - OptionsPane( - label: 'PERFORMANCE FACTORS', - children: [ - NumericalInputRow( - label: 'Parallel Threads', - suffixText: 'threads', - value: (provider) => provider.parallelThreads, - min: 1, - max: 10, - onChanged: (provider, value) => - provider.parallelThreads = value, - ), - NumericalInputRow( - label: 'Rate Limit', - suffixText: 'max. tps', - value: (provider) => provider.rateLimit, - min: 1, - max: 300, - maxEligibleTilesPreview: 20, - onChanged: (provider, value) => - provider.rateLimit = value, - ), - NumericalInputRow( - label: 'Tile Buffer Length', - suffixText: 'max. tiles', - value: (provider) => provider.maxBufferLength, - min: 0, - max: null, - onChanged: (provider, value) => - provider.maxBufferLength = value, - ), - ], - ), - OptionsPane( - label: 'SKIP TILES', - children: [ - Row( - children: [ - const Text('Skip Existing Tiles'), - const Spacer(), - Switch.adaptive( - value: context - .select( - (provider) => provider.skipExistingTiles, - ), - onChanged: (val) => context - .read() - .skipExistingTiles = val, - activeColor: - Theme.of(context).colorScheme.primary, - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Skip Sea Tiles'), - const Spacer(), - Switch.adaptive( - value: context.select((provider) => provider.skipSeaTiles), - onChanged: (val) => context - .read() - .skipSeaTiles = val, - activeColor: - Theme.of(context).colorScheme.primary, - ), - ], - ), - ], - ), - const SizedBox(height: 72), - ].interleave(const SizedBox.square(dimension: 16)).toList(), - ), - ), - ), - Selector( - selector: (context, provider) => provider.isReady, - builder: (context, isReady, _) => IgnorePointer( - ignoring: !isReady, - child: GestureDetector( - onTap: isReady - ? () => context - .read() - .isReady = false - : null, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInCubic, - color: isReady - ? Colors.black.withOpacity(2 / 3) - : Colors.transparent, - ), - ), - ), - ), - ], - ), - ); -} diff --git a/example/lib/screens/configure_download/state/configure_download_provider.dart b/example/lib/screens/configure_download/state/configure_download_provider.dart deleted file mode 100644 index d7ce1387..00000000 --- a/example/lib/screens/configure_download/state/configure_download_provider.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/foundation.dart'; - -class ConfigureDownloadProvider extends ChangeNotifier { - static const defaultValues = { - 'parallelThreads': 5, - 'rateLimit': 200, - 'maxBufferLength': 500, - }; - - int _parallelThreads = defaultValues['parallelThreads']!; - int get parallelThreads => _parallelThreads; - set parallelThreads(int newNum) { - _parallelThreads = newNum; - notifyListeners(); - } - - int _rateLimit = defaultValues['rateLimit']!; - int get rateLimit => _rateLimit; - set rateLimit(int newNum) { - _rateLimit = newNum; - notifyListeners(); - } - - int _maxBufferLength = defaultValues['maxBufferLength']!; - int get maxBufferLength => _maxBufferLength; - set maxBufferLength(int newNum) { - _maxBufferLength = newNum; - notifyListeners(); - } - - bool _skipExistingTiles = true; - bool get skipExistingTiles => _skipExistingTiles; - set skipExistingTiles(bool newState) { - _skipExistingTiles = newState; - notifyListeners(); - } - - bool _skipSeaTiles = true; - bool get skipSeaTiles => _skipSeaTiles; - set skipSeaTiles(bool newState) { - _skipSeaTiles = newState; - notifyListeners(); - } - - bool _isReady = false; - bool get isReady => _isReady; - set isReady(bool newState) { - _isReady = newState; - notifyListeners(); - } -} diff --git a/example/lib/screens/export_import/components/directory_selected.dart b/example/lib/screens/export_import/components/directory_selected.dart deleted file mode 100644 index e7cf2362..00000000 --- a/example/lib/screens/export_import/components/directory_selected.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; - -class DirectorySelected extends StatelessWidget { - const DirectorySelected({super.key}); - - @override - Widget build(BuildContext context) => const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.snippet_folder_rounded, size: 48), - Text( - 'Input/select a file (not a directory)', - style: TextStyle(fontSize: 15), - ), - ], - ); -} diff --git a/example/lib/screens/export_import/components/export.dart b/example/lib/screens/export_import/components/export.dart deleted file mode 100644 index 5ec8add0..00000000 --- a/example/lib/screens/export_import/components/export.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../../shared/components/loading_indicator.dart'; - -class Export extends StatefulWidget { - const Export({ - super.key, - required this.selectedStores, - }); - - final Set selectedStores; - - @override - State createState() => _ExportState(); -} - -class _ExportState extends State { - late final stores = FMTCRoot.stats.storesAvailable; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Export Stores To Archive', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - Expanded( - child: FutureBuilder( - future: stores, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const LoadingIndicator('Loading exportable stores'); - } - - if (snapshot.data!.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.folder_off_rounded, size: 48), - Text( - "There aren't any stores to export!", - style: TextStyle(fontSize: 15), - ), - ], - ), - ); - } - - final availableStores = - snapshot.data!.map((e) => e.storeName).toList(); - - return Wrap( - spacing: 10, - runSpacing: 10, - children: List.generate( - availableStores.length, - (i) { - final storeName = availableStores[i]; - return ChoiceChip( - label: Text(storeName), - selected: widget.selectedStores.contains(storeName), - onSelected: (selected) { - if (selected) { - widget.selectedStores.add(storeName); - } else { - widget.selectedStores.remove(storeName); - } - setState(() {}); - }, - ); - }, - growable: false, - ), - ); - }, - ), - ), - ], - ); -} diff --git a/example/lib/screens/export_import/components/import.dart b/example/lib/screens/export_import/components/import.dart deleted file mode 100644 index 03b681b4..00000000 --- a/example/lib/screens/export_import/components/import.dart +++ /dev/null @@ -1,208 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../../shared/components/loading_indicator.dart'; - -class Import extends StatefulWidget { - const Import({ - super.key, - required this.path, - required this.changeForceOverrideExisting, - required this.conflictStrategy, - required this.changeConflictStrategy, - }); - - final String path; - final void Function({required bool forceOverrideExisting}) - changeForceOverrideExisting; - - final ImportConflictStrategy conflictStrategy; - final void Function(ImportConflictStrategy) changeConflictStrategy; - - @override - State createState() => _ImportState(); -} - -class _ImportState extends State { - late final _conflictStrategies = - ImportConflictStrategy.values.toList(growable: false); - late Future> importableStores = - FMTCRoot.external(pathToArchive: widget.path).listStores; - - @override - void didUpdateWidget(covariant Import oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.path != widget.path) { - importableStores = - FMTCRoot.external(pathToArchive: widget.path).listStores; - } - } - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Import Stores From Archive', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - OutlinedButton.icon( - onPressed: () => widget.changeForceOverrideExisting( - forceOverrideExisting: true, - ), - icon: const Icon(Icons.file_upload_outlined), - label: const Text('Force Overwrite'), - ), - ], - ), - const SizedBox(height: 16), - Text( - 'Importable Stores', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Flexible( - child: FutureBuilder( - future: importableStores, - builder: (context, snapshot) { - if (snapshot.hasError) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.broken_image_rounded, size: 48), - Text( - "We couldn't open that archive.\nAre you sure it's " - 'compatible with FMTC, and is unmodified?', - style: TextStyle(fontSize: 15), - textAlign: TextAlign.center, - ), - ], - ), - ); - } - - if (!snapshot.hasData) { - return const LoadingIndicator('Loading importable stores'); - } - - if (snapshot.data!.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.folder_off_rounded, size: 48), - Text( - "There aren't any stores to import!\n" - 'Check that you exported it correctly.', - style: TextStyle(fontSize: 15), - ), - ], - ), - ); - } - - return ListView.separated( - shrinkWrap: true, - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - final storeName = snapshot.data![index]; - - return ListTile( - title: Text(storeName), - subtitle: FutureBuilder( - future: FMTCStore(storeName).manage.ready, - builder: (context, snapshot) => Text( - switch (snapshot.data) { - null => 'Checking for conflicts...', - true => 'Conflicts with existing store', - false => 'No conflicts', - }, - ), - ), - dense: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FutureBuilder( - future: FMTCStore(storeName).manage.ready, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator.adaptive( - strokeWidth: 3, - ), - ); - } - if (snapshot.data!) { - return const Icon(Icons.merge_type_rounded); - } - return const SizedBox.shrink(); - }, - ), - const SizedBox(width: 10), - const Icon(Icons.pending_outlined), - ], - ), - ); - }, - separatorBuilder: (context, index) => const Divider(), - ); - }, - ), - ), - const SizedBox(height: 16), - Text( - 'Conflict Strategy', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: DropdownButton( - isExpanded: true, - value: widget.conflictStrategy, - items: _conflictStrategies - .map( - (e) => DropdownMenuItem( - value: e, - child: Row( - children: [ - Icon( - switch (e) { - ImportConflictStrategy.merge => - Icons.merge_rounded, - ImportConflictStrategy.rename => - Icons.edit_rounded, - ImportConflictStrategy.replace => - Icons.save_as_rounded, - ImportConflictStrategy.skip => - Icons.skip_next_rounded, - }, - ), - const SizedBox(width: 8), - Text( - switch (e) { - ImportConflictStrategy.merge => 'Merge', - ImportConflictStrategy.rename => 'Rename', - ImportConflictStrategy.replace => 'Replace', - ImportConflictStrategy.skip => 'Skip', - }, - style: const TextStyle(color: Colors.white), - ), - ], - ), - ), - ) - .toList(growable: false), - onChanged: (choice) => widget.changeConflictStrategy(choice!), - ), - ), - ], - ); -} diff --git a/example/lib/screens/export_import/components/no_path_selected.dart b/example/lib/screens/export_import/components/no_path_selected.dart deleted file mode 100644 index 7e4b6b35..00000000 --- a/example/lib/screens/export_import/components/no_path_selected.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; - -class NoPathSelected extends StatelessWidget { - const NoPathSelected({super.key}); - - @override - Widget build(BuildContext context) => const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.keyboard_rounded, size: 48), - Text( - 'To get started, input/select a path to a file', - style: TextStyle(fontSize: 15), - ), - ], - ); -} diff --git a/example/lib/screens/export_import/components/path_picker.dart b/example/lib/screens/export_import/components/path_picker.dart deleted file mode 100644 index ab42d2ae..00000000 --- a/example/lib/screens/export_import/components/path_picker.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:path/path.dart' as path; - -class PathPicker extends StatelessWidget { - const PathPicker({ - super.key, - required this.pathController, - required this.onPathChanged, - }); - - final TextEditingController pathController; - final void Function({required bool forceOverrideExisting}) onPathChanged; - - @override - Widget build(BuildContext context) { - final isDesktop = Theme.of(context).platform == TargetPlatform.linux || - Theme.of(context).platform == TargetPlatform.windows || - Theme.of(context).platform == TargetPlatform.macOS; - - return IntrinsicWidth( - child: Column( - children: [ - if (isDesktop) - Row( - children: [ - OutlinedButton.icon( - onPressed: () async { - final picked = await FilePicker.platform.saveFile( - type: FileType.custom, - allowedExtensions: ['fmtc'], - dialogTitle: 'Export To File', - ); - if (picked != null) { - pathController.value = TextEditingValue( - text: '$picked.fmtc', - selection: TextSelection.collapsed( - offset: picked.length, - ), - ); - onPathChanged(forceOverrideExisting: true); - } - }, - icon: const Icon(Icons.file_upload_outlined), - label: const Text('Export'), - ), - const SizedBox.square(dimension: 8), - SizedBox.square( - dimension: 32, - child: IconButton.outlined( - onPressed: () async { - final picked = await FilePicker.platform.getDirectoryPath( - dialogTitle: 'Export To Directory', - ); - if (picked != null) { - final finalPath = path.join(picked, 'archive.fmtc'); - - pathController.value = TextEditingValue( - text: finalPath, - selection: TextSelection.collapsed( - offset: finalPath.length, - ), - ); - - onPathChanged(forceOverrideExisting: true); - } - }, - iconSize: 16, - icon: Icon( - Icons.folder, - color: Theme.of(context) - .buttonTheme - .colorScheme! - .primaryFixed, - ), - ), - ), - ], - ) - else - OutlinedButton.icon( - onPressed: () async { - if (isDesktop) { - final picked = await FilePicker.platform.saveFile( - type: FileType.custom, - allowedExtensions: ['fmtc'], - dialogTitle: 'Export', - ); - if (picked != null) { - pathController.value = TextEditingValue( - text: '$picked.fmtc', - selection: TextSelection.collapsed( - offset: picked.length, - ), - ); - - onPathChanged(forceOverrideExisting: true); - } - } else { - final picked = await FilePicker.platform.getDirectoryPath( - dialogTitle: 'Export', - ); - if (picked != null) { - final finalPath = path.join(picked, 'archive.fmtc'); - - pathController.value = TextEditingValue( - text: finalPath, - selection: TextSelection.collapsed( - offset: finalPath.length, - ), - ); - - onPathChanged(forceOverrideExisting: true); - } - } - }, - icon: const Icon(Icons.file_upload_outlined), - label: const Text('Export'), - ), - const SizedBox.square(dimension: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () async { - final picked = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['fmtc'], - dialogTitle: 'Import', - ); - if (picked != null) { - pathController.value = TextEditingValue( - text: picked.files.single.path!, - selection: TextSelection.collapsed( - offset: picked.files.single.path!.length, - ), - ); - - onPathChanged(forceOverrideExisting: false); - } - }, - icon: const Icon(Icons.file_download_outlined), - label: const Text('Import'), - ), - ), - ], - ), - ); - } -} diff --git a/example/lib/screens/export_import/export_import.dart b/example/lib/screens/export_import/export_import.dart deleted file mode 100644 index d0302f36..00000000 --- a/example/lib/screens/export_import/export_import.dart +++ /dev/null @@ -1,236 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../shared/components/loading_indicator.dart'; -import 'components/directory_selected.dart'; -import 'components/export.dart'; -import 'components/import.dart'; -import 'components/no_path_selected.dart'; -import 'components/path_picker.dart'; - -class ExportImportPopup extends StatefulWidget { - const ExportImportPopup({super.key}); - - @override - State createState() => _ExportImportPopupState(); -} - -class _ExportImportPopupState extends State { - final pathController = TextEditingController(); - - final selectedStores = {}; - Future? typeOfPath; - bool forceOverrideExisting = false; - ImportConflictStrategy selectedConflictStrategy = ImportConflictStrategy.skip; - bool isProcessing = false; - - void onPathChanged({required bool forceOverrideExisting}) => setState(() { - this.forceOverrideExisting = forceOverrideExisting; - typeOfPath = FileSystemEntity.type(pathController.text); - }); - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Export/Import Stores'), - ), - body: Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, - width: 2, - ), - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Expanded( - child: TextField( - controller: pathController, - decoration: const InputDecoration( - label: Text('Path To Archive'), - hintText: 'folder/archive.fmtc', - isDense: true, - ), - onEditingComplete: () => - onPathChanged(forceOverrideExisting: false), - ), - ), - const SizedBox.square(dimension: 12), - PathPicker( - pathController: pathController, - onPathChanged: onPathChanged, - ), - ], - ), - ), - Expanded( - child: pathController.text != '' && !isProcessing - ? SizedBox( - width: double.infinity, - child: FutureBuilder( - future: typeOfPath, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const LoadingIndicator( - 'Checking whether the path exists', - ); - } - - if (snapshot.data! == - FileSystemEntityType.notFound || - forceOverrideExisting) { - return Padding( - padding: const EdgeInsets.only( - top: 24, - left: 12, - right: 12, - ), - child: Export( - selectedStores: selectedStores, - ), - ); - } - - if (snapshot.data! != FileSystemEntityType.file) { - return const DirectorySelected(); - } - - return Padding( - padding: const EdgeInsets.only( - top: 24, - left: 12, - right: 12, - ), - child: Import( - path: pathController.text, - changeForceOverrideExisting: onPathChanged, - conflictStrategy: selectedConflictStrategy, - changeConflictStrategy: (c) => setState( - () => selectedConflictStrategy = c, - ), - ), - ); - }, - ), - ) - : pathController.text == '' - ? const NoPathSelected() - : const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator.adaptive(), - SizedBox(height: 12), - Text( - 'Exporting/importing your stores, tiles, and metadata', - textAlign: TextAlign.center, - ), - Text( - 'This could take a while, please be patient', - textAlign: TextAlign.center, - style: TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - ), - ), - ], - ), - ), - floatingActionButton: FutureBuilder( - future: typeOfPath, - builder: (context, snapshot) { - if (!snapshot.hasData || - (snapshot.data! != FileSystemEntityType.file && - snapshot.data! != FileSystemEntityType.notFound)) { - return const SizedBox.shrink(); - } - - late final bool isExporting; - late final Icon icon; - if (snapshot.data! == FileSystemEntityType.notFound) { - icon = const Icon(Icons.save); - isExporting = true; - } else if (snapshot.data! == FileSystemEntityType.file && - forceOverrideExisting) { - icon = const Icon(Icons.save_as); - isExporting = true; - } else { - icon = const Icon(Icons.file_open_rounded); - isExporting = false; - } - - return FloatingActionButton( - heroTag: 'importExport', - onPressed: isProcessing - ? null - : () async { - if (isExporting) { - setState(() => isProcessing = true); - final stopwatch = Stopwatch()..start(); - await FMTCRoot.external( - pathToArchive: pathController.text, - ).export( - storeNames: selectedStores.toList(), - ); - stopwatch.stop(); - if (context.mounted) { - final elapsedTime = - (stopwatch.elapsedMilliseconds / 1000) - .toStringAsFixed(1); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Successfully exported stores (in $elapsedTime ' - 'secs)', - ), - ), - ); - Navigator.pop(context); - } - } else { - setState(() => isProcessing = true); - final stopwatch = Stopwatch()..start(); - final importResult = FMTCRoot.external( - pathToArchive: pathController.text, - ).import( - strategy: selectedConflictStrategy, - ); - final numImportedTiles = await importResult.complete; - stopwatch.stop(); - if (context.mounted) { - final elapsedTime = - (stopwatch.elapsedMilliseconds / 1000) - .toStringAsFixed(1); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Successfully imported $numImportedTiles tiles ' - '(in $elapsedTime secs)', - ), - ), - ); - Navigator.pop(context); - } - } - }, - child: isProcessing - ? const SizedBox.square( - dimension: 26, - child: CircularProgressIndicator.adaptive(), - ) - : icon, - ); - }, - ), - ); -} diff --git a/example/lib/screens/main/main.dart b/example/lib/screens/main/main.dart deleted file mode 100644 index bb756d9e..00000000 --- a/example/lib/screens/main/main.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:badges/badges.dart'; -import 'package:flutter/material.dart' hide Badge; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import 'pages/downloading/downloading.dart'; -import 'pages/downloading/state/downloading_provider.dart'; -import 'pages/map/map_page.dart'; -import 'pages/recovery/recovery.dart'; -import 'pages/region_selection/region_selection.dart'; -import 'pages/stores/stores.dart'; - -class MainScreen extends StatefulWidget { - const MainScreen({super.key}); - - @override - State createState() => _MainScreenState(); -} - -class _MainScreenState extends State { - late final _pageController = PageController(initialPage: _currentPageIndex); - int _currentPageIndex = 0; - bool extended = false; - - List get _destinations => [ - const NavigationDestination( - label: 'Map', - icon: Icon(Icons.map_outlined), - selectedIcon: Icon(Icons.map), - ), - const NavigationDestination( - label: 'Stores', - icon: Icon(Icons.folder_outlined), - selectedIcon: Icon(Icons.folder), - ), - const NavigationDestination( - label: 'Download', - icon: Icon(Icons.download_outlined), - selectedIcon: Icon(Icons.download), - ), - NavigationDestination( - label: 'Recover', - icon: StreamBuilder( - stream: FMTCRoot.stats.watchRecovery(), - builder: (context, _) => FutureBuilder( - future: FMTCRoot.recovery.recoverableRegions, - builder: (context, snapshot) => Badge( - position: BadgePosition.topEnd(top: -5, end: -6), - badgeAnimation: const BadgeAnimation.size( - animationDuration: Duration(milliseconds: 100), - ), - showBadge: _currentPageIndex != 3 && - (snapshot.data?.failedOnly.isNotEmpty ?? false), - child: const Icon(Icons.support), - ), - ), - ), - ), - ]; - - List get _pages => [ - const MapPage(), - const StoresPage(), - Selector?>( - selector: (context, provider) => provider.downloadProgress, - builder: (context, downloadProgress, _) => downloadProgress == null - ? const RegionSelectionPage() - : DownloadingPage( - moveToMapPage: () => - _onDestinationSelected(0, cancelTilesPreview: false), - ), - ), - RecoveryPage(moveToDownloadPage: () => _onDestinationSelected(2)), - ]; - - void _onDestinationSelected(int index, {bool cancelTilesPreview = true}) { - setState(() => _currentPageIndex = index); - _pageController - .animateToPage( - _currentPageIndex, - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - ) - .then( - (_) { - if (cancelTilesPreview) { - final dp = context.read(); - dp.tilesPreviewStreamSub - ?.cancel() - .then((_) => dp.tilesPreviewStreamSub = null); - } - }, - ); - } - - @override - void dispose() { - _pageController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => Scaffold( - bottomNavigationBar: MediaQuery.sizeOf(context).width > 950 - ? null - : NavigationBar( - onDestinationSelected: _onDestinationSelected, - selectedIndex: _currentPageIndex, - destinations: _destinations, - labelBehavior: - NavigationDestinationLabelBehavior.onlyShowSelected, - height: 70, - ), - body: Row( - children: [ - if (MediaQuery.sizeOf(context).width > 950) - NavigationRail( - onDestinationSelected: _onDestinationSelected, - selectedIndex: _currentPageIndex, - labelType: NavigationRailLabelType.all, - groupAlignment: 0, - destinations: _destinations - .map( - (d) => NavigationRailDestination( - label: Text(d.label), - icon: d.icon, - selectedIcon: d.selectedIcon, - padding: const EdgeInsets.symmetric( - vertical: 6, - horizontal: 3, - ), - ), - ) - .toList(), - ), - Expanded( - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - left: MediaQuery.sizeOf(context).width > 950 - ? BorderSide(color: Theme.of(context).dividerColor) - : BorderSide.none, - ), - ), - position: DecorationPosition.foreground, - child: PageView( - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), - children: _pages, - ), - ), - ), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/downloading/components/download_layout.dart b/example/lib/screens/main/pages/downloading/components/download_layout.dart deleted file mode 100644 index 70d726ca..00000000 --- a/example/lib/screens/main/pages/downloading/components/download_layout.dart +++ /dev/null @@ -1,268 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../shared/misc/exts/size_formatter.dart'; -import '../state/downloading_provider.dart'; -import 'main_statistics.dart'; -import 'multi_linear_progress_indicator.dart'; -import 'stat_display.dart'; - -part 'stats_table.dart'; - -class DownloadLayout extends StatelessWidget { - const DownloadLayout({ - super.key, - required this.storeDirectory, - required this.download, - required this.moveToMapPage, - }); - - final FMTCStore storeDirectory; - final DownloadProgress download; - final void Function() moveToMapPage; - - @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth > 800; - - return SingleChildScrollView( - child: Column( - children: [ - IntrinsicHeight( - child: Flex( - direction: isWide ? Axis.horizontal : Axis.vertical, - children: [ - Expanded( - child: Wrap( - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 32, - runSpacing: 28, - children: [ - RepaintBoundary( - child: SizedBox.square( - dimension: isWide ? 216 : 196, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: download.latestTileEvent.tileImage != - null - ? Image.memory( - download.latestTileEvent.tileImage!, - gaplessPlayback: true, - ) - : const Center( - child: CircularProgressIndicator - .adaptive(), - ), - ), - ), - ), - MainStatistics( - download: download, - storeDirectory: storeDirectory, - moveToMapPage: moveToMapPage, - ), - ], - ), - ), - const SizedBox.square(dimension: 16), - if (isWide) const VerticalDivider() else const Divider(), - const SizedBox.square(dimension: 16), - if (isWide) - Expanded(child: _StatsTable(download: download)) - else - _StatsTable(download: download), - ], - ), - ), - const SizedBox(height: 30), - MulitLinearProgressIndicator( - maxValue: download.maxTiles, - backgroundChild: Text( - '${download.remainingTiles}', - style: const TextStyle(color: Colors.white), - ), - progresses: [ - ( - value: download.cachedTiles + - download.skippedTiles + - download.failedTiles, - color: Colors.red, - child: Text( - '${download.failedTiles}', - style: const TextStyle(color: Colors.black), - ) - ), - ( - value: download.cachedTiles + download.skippedTiles, - color: Colors.yellow, - child: Text( - '${download.skippedTiles}', - style: const TextStyle(color: Colors.black), - ) - ), - ( - value: download.cachedTiles, - color: Colors.green[300]!, - child: Text( - '${download.bufferedTiles}', - style: const TextStyle(color: Colors.black), - ) - ), - ( - value: download.cachedTiles - download.bufferedTiles, - color: Colors.green, - child: Text( - '${download.cachedTiles - download.bufferedTiles}', - style: const TextStyle(color: Colors.white), - ) - ), - ], - ), - const SizedBox(height: 32), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RotatedBox( - quarterTurns: 3, - child: Text( - 'FAILED TILES', - style: GoogleFonts.ubuntu( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: RepaintBoundary( - child: Selector>( - selector: (context, provider) => provider.failedTiles, - builder: (context, failedTiles, _) { - final hasFailedTiles = failedTiles.isEmpty; - if (hasFailedTiles) { - return const Padding( - padding: EdgeInsets.symmetric(horizontal: 2), - child: Column( - children: [ - Icon(Icons.task_alt, size: 48), - SizedBox(height: 10), - Text( - 'Any failed tiles will appear here', - textAlign: TextAlign.center, - ), - ], - ), - ); - } - return ListView.builder( - reverse: true, - addRepaintBoundaries: false, - itemCount: failedTiles.length, - shrinkWrap: true, - itemBuilder: (context, index) => ListTile( - leading: Icon( - switch (failedTiles[index].result) { - TileEventResult.noConnectionDuringFetch => - Icons.wifi_off, - TileEventResult.unknownFetchException => - Icons.error, - TileEventResult.negativeFetchResponse => - Icons.reply, - _ => Icons.abc, - }, - ), - title: Text(failedTiles[index].url), - subtitle: Text( - switch (failedTiles[index].result) { - TileEventResult.noConnectionDuringFetch => - 'Failed to establish a connection to the network', - TileEventResult.unknownFetchException => - 'There was an unknown error when trying to download this tile, of type ${failedTiles[index].fetchError.runtimeType}', - TileEventResult.negativeFetchResponse => - 'The tile server responded with an HTTP status code of ${failedTiles[index].fetchResponse!.statusCode} (${failedTiles[index].fetchResponse!.reasonPhrase})', - _ => throw Error(), - }, - ), - ), - ); - }, - ), - ), - ), - const SizedBox(width: 8), - RotatedBox( - quarterTurns: 3, - child: Text( - 'SKIPPED TILES', - style: GoogleFonts.ubuntu( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: RepaintBoundary( - child: Selector>( - selector: (context, provider) => - provider.skippedTiles, - builder: (context, skippedTiles, _) { - final hasSkippedTiles = skippedTiles.isEmpty; - if (hasSkippedTiles) { - return const Padding( - padding: EdgeInsets.symmetric(horizontal: 2), - child: Column( - children: [ - Icon(Icons.task_alt, size: 48), - SizedBox(height: 10), - Text( - 'Any skipped tiles will appear here', - textAlign: TextAlign.center, - ), - ], - ), - ); - } - - return ListView.builder( - reverse: true, - addRepaintBoundaries: false, - itemCount: skippedTiles.length, - shrinkWrap: true, - itemBuilder: (context, index) => ListTile( - leading: Icon( - switch (skippedTiles[index].result) { - TileEventResult.alreadyExisting => - Icons.disabled_visible, - TileEventResult.isSeaTile => - Icons.water_drop, - _ => Icons.abc, - }, - ), - title: Text(skippedTiles[index].url), - subtitle: Text( - switch (skippedTiles[index].result) { - TileEventResult.alreadyExisting => - 'Tile already exists', - TileEventResult.isSeaTile => - 'Tile is a sea tile', - _ => throw Error(), - }, - ), - ), - ); - }, - ), - ), - ), - ], - ), - ], - ), - ); - }, - ); -} diff --git a/example/lib/screens/main/pages/downloading/components/main_statistics.dart b/example/lib/screens/main/pages/downloading/components/main_statistics.dart deleted file mode 100644 index 1e8c8bf6..00000000 --- a/example/lib/screens/main/pages/downloading/components/main_statistics.dart +++ /dev/null @@ -1,181 +0,0 @@ -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:provider/provider.dart'; - -import '../../map/state/map_provider.dart'; -import '../state/downloading_provider.dart'; -import 'stat_display.dart'; - -const _tileSize = 256; -const _offset = Offset(-(_tileSize / 2), -(_tileSize / 2)); - -class MainStatistics extends StatefulWidget { - const MainStatistics({ - super.key, - required this.download, - required this.storeDirectory, - required this.moveToMapPage, - }); - - final DownloadProgress download; - final FMTCStore storeDirectory; - final void Function() moveToMapPage; - - @override - State createState() => _MainStatisticsState(); -} - -class _MainStatisticsState extends State { - @override - Widget build(BuildContext context) => IntrinsicWidth( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - RepaintBoundary( - child: Text( - '${widget.download.attemptedTiles}/${widget.download.maxTiles} (${widget.download.percentageProgress.toStringAsFixed(2)}%)', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 16), - StatDisplay( - statistic: - '${widget.download.elapsedDuration.toString().split('.')[0]} / ${widget.download.estTotalDuration.toString().split('.')[0]}', - description: 'elapsed / estimated total duration', - ), - StatDisplay( - statistic: - widget.download.estRemainingDuration.toString().split('.')[0], - description: 'estimated remaining duration', - ), - RepaintBoundary( - child: Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.download.tilesPerSecond.toStringAsFixed(2), - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: widget.download.isTPSArtificiallyCapped - ? Colors.amber - : null, - ), - ), - if (widget.download.isTPSArtificiallyCapped) ...[ - const SizedBox(width: 8), - const Icon(Icons.lock_clock, color: Colors.amber), - ], - ], - ), - Text( - 'approx. tiles per second', - style: TextStyle( - fontSize: 16, - color: widget.download.isTPSArtificiallyCapped - ? Colors.amber - : null, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - if (!widget.download.isComplete) - RepaintBoundary( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton.filled( - onPressed: () { - final mp = context.read(); - - final dp = context.read(); - - dp - ..tilesPreviewStreamSub = - dp.downloadProgress?.listen((prog) { - final lte = prog.latestTileEvent; - if (!lte.isRepeat) { - if (dp.tilesPreview.isNotEmpty && - lte.coordinates.z != - dp.tilesPreview.keys.first.z) { - dp.clearTilesPreview(); - } - dp.addTilePreview(lte.coordinates, lte.tileImage); - } - - final zoom = lte.coordinates.z.toDouble(); - - mp.animateTo( - dest: mp.mapController.camera.unproject( - lte.coordinates.toIntPoint() * _tileSize, - zoom, - ), - zoom: zoom, - offset: _offset, - ); - }) - ..showQuitTilesPreviewIndicator = true; - - Future.delayed( - const Duration(seconds: 3), - () => dp.showQuitTilesPreviewIndicator = false, - ); - - widget.moveToMapPage(); - }, - icon: const Icon(Icons.visibility), - tooltip: 'Follow Download On Map', - ), - const SizedBox(width: 24), - IconButton.outlined( - onPressed: () async { - if (widget.storeDirectory.download.isPaused()) { - widget.storeDirectory.download.resume(); - } else { - await widget.storeDirectory.download.pause(); - } - setState(() {}); - }, - icon: Icon( - widget.storeDirectory.download.isPaused() - ? Icons.play_arrow - : Icons.pause, - ), - ), - const SizedBox(width: 12), - IconButton.outlined( - onPressed: () => widget.storeDirectory.download.cancel(), - icon: const Icon(Icons.cancel), - ), - ], - ), - ), - if (widget.download.isComplete) - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () { - WidgetsBinding.instance.addPostFrameCallback( - (_) => context - .read() - .setDownloadProgress(null), - ); - }, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Text('Exit'), - ), - ), - ), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/downloading/components/multi_linear_progress_indicator.dart b/example/lib/screens/main/pages/downloading/components/multi_linear_progress_indicator.dart deleted file mode 100644 index 1881fc65..00000000 --- a/example/lib/screens/main/pages/downloading/components/multi_linear_progress_indicator.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; - -typedef IndividualProgress = ({num value, Color color, Widget? child}); - -class MulitLinearProgressIndicator extends StatefulWidget { - const MulitLinearProgressIndicator({ - super.key, - required this.progresses, - this.maxValue = 1, - this.backgroundChild, - this.height = 24, - this.radius, - this.childAlignment = Alignment.centerRight, - this.animationDuration = const Duration(milliseconds: 500), - }); - - final List progresses; - final num maxValue; - final Widget? backgroundChild; - final double height; - final BorderRadiusGeometry? radius; - final AlignmentGeometry childAlignment; - final Duration animationDuration; - - @override - State createState() => - _MulitLinearProgressIndicatorState(); -} - -class _MulitLinearProgressIndicatorState - extends State { - @override - Widget build(BuildContext context) => RepaintBoundary( - child: LayoutBuilder( - builder: (context, constraints) => ClipRRect( - borderRadius: - widget.radius ?? BorderRadius.circular(widget.height / 2), - child: SizedBox( - height: widget.height, - width: constraints.maxWidth, - child: Stack( - children: [ - Positioned.fill( - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: widget.radius ?? - BorderRadius.circular(widget.height / 2), - border: Border.all( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - padding: EdgeInsets.symmetric( - vertical: 2, - horizontal: widget.height / 2, - ), - alignment: widget.childAlignment, - child: widget.backgroundChild, - ), - ), - ...widget.progresses.map( - (e) => AnimatedPositioned( - height: widget.height, - left: 0, - width: (constraints.maxWidth / widget.maxValue) * e.value, - duration: widget.animationDuration, - child: Container( - decoration: BoxDecoration( - color: e.color, - borderRadius: widget.radius ?? - BorderRadius.circular(widget.height / 2), - ), - padding: EdgeInsets.symmetric( - vertical: 2, - horizontal: widget.height / 2, - ), - alignment: widget.childAlignment, - child: e.child, - ), - ), - ), - ], - ), - ), - ), - ), - ); -} diff --git a/example/lib/screens/main/pages/downloading/components/stat_display.dart b/example/lib/screens/main/pages/downloading/components/stat_display.dart deleted file mode 100644 index 3592c850..00000000 --- a/example/lib/screens/main/pages/downloading/components/stat_display.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -class StatDisplay extends StatelessWidget { - const StatDisplay({ - super.key, - required this.statistic, - required this.description, - }); - - final String statistic; - final String description; - - @override - Widget build(BuildContext context) => RepaintBoundary( - child: Column( - children: [ - Text( - statistic, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - Text( - description, - style: const TextStyle(fontSize: 16), - textAlign: TextAlign.center, - ), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/downloading/components/stats_table.dart b/example/lib/screens/main/pages/downloading/components/stats_table.dart deleted file mode 100644 index 7c312023..00000000 --- a/example/lib/screens/main/pages/downloading/components/stats_table.dart +++ /dev/null @@ -1,83 +0,0 @@ -part of 'download_layout.dart'; - -class _StatsTable extends StatelessWidget { - const _StatsTable({ - required this.download, - }); - - final DownloadProgress download; - - @override - Widget build(BuildContext context) => Table( - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [ - TableRow( - children: [ - StatDisplay( - statistic: - '${download.cachedTiles - download.bufferedTiles} + ${download.bufferedTiles}', - description: 'cached + buffered tiles', - ), - StatDisplay( - statistic: - '${((download.cachedSize - download.bufferedSize) * 1024).asReadableSize} + ${(download.bufferedSize * 1024).asReadableSize}', - description: 'cached + buffered size', - ), - ], - ), - TableRow( - children: [ - StatDisplay( - statistic: - '${download.skippedTiles} (${download.skippedTiles == 0 ? 0 : (100 - ((download.cachedTiles - download.skippedTiles) / download.cachedTiles) * 100).clamp(double.minPositive, 100).toStringAsFixed(1)}%)', - description: 'skipped tiles (% saving)', - ), - StatDisplay( - statistic: - '${(download.skippedSize * 1024).asReadableSize} (${download.skippedTiles == 0 ? 0 : (100 - ((download.cachedSize - download.skippedSize) / download.cachedSize) * 100).clamp(double.minPositive, 100).toStringAsFixed(1)}%)', - description: 'skipped size (% saving)', - ), - ], - ), - TableRow( - children: [ - RepaintBoundary( - child: Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - download.failedTiles.toString(), - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: - download.failedTiles == 0 ? null : Colors.red, - ), - ), - if (download.failedTiles != 0) ...[ - const SizedBox(width: 8), - const Icon( - Icons.warning_amber, - color: Colors.red, - ), - ], - ], - ), - Text( - 'failed tiles', - style: TextStyle( - fontSize: 16, - color: download.failedTiles == 0 ? null : Colors.red, - ), - ), - ], - ), - ), - const SizedBox.shrink(), - ], - ), - ], - ); -} diff --git a/example/lib/screens/main/pages/downloading/downloading.dart b/example/lib/screens/main/pages/downloading/downloading.dart deleted file mode 100644 index f113d800..00000000 --- a/example/lib/screens/main/pages/downloading/downloading.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:provider/provider.dart'; - -import '../region_selection/state/region_selection_provider.dart'; -import 'components/download_layout.dart'; -import 'state/downloading_provider.dart'; - -class DownloadingPage extends StatefulWidget { - const DownloadingPage({super.key, required this.moveToMapPage}); - - final void Function() moveToMapPage; - - @override - State createState() => _DownloadingPageState(); -} - -class _DownloadingPageState extends State - with AutomaticKeepAliveClientMixin { - StreamSubscription? downloadProgressStreamSubscription; - - @override - void didChangeDependencies() { - final provider = context.read(); - - downloadProgressStreamSubscription?.cancel(); - downloadProgressStreamSubscription = - provider.downloadProgress!.listen((event) { - final latestTileEvent = event.latestTileEvent; - if (latestTileEvent.isRepeat) return; - - if (latestTileEvent.result.category == TileEventResultCategory.failed) { - provider.addFailedTile(latestTileEvent); - } - if (latestTileEvent.result.category == TileEventResultCategory.skipped) { - provider.addSkippedTile(latestTileEvent); - } - }); - - super.didChangeDependencies(); - } - - @override - void dispose() { - downloadProgressStreamSubscription?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - super.build(context); - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Selector( - selector: (context, provider) => provider.selectedStore, - builder: (context, selectedStore, _) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Downloading', - style: GoogleFonts.ubuntu( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - Text( - 'Downloading To: ${selectedStore!.storeName}', - overflow: TextOverflow.fade, - softWrap: false, - ), - ], - ), - ), - const SizedBox(height: 12), - Expanded( - child: Padding( - padding: const EdgeInsets.all(6), - child: StreamBuilder( - stream: context - .select?>( - (provider) => provider.downloadProgress, - ), - builder: (context, snapshot) { - if (snapshot.data == null) { - return const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator.adaptive(), - SizedBox(height: 16), - Text( - 'Taking a while?', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Text( - 'Please wait for the download to start...', - ), - ], - ), - ); - } - - return DownloadLayout( - storeDirectory: - context.select( - (provider) => provider.selectedStore, - )!, - download: snapshot.data!, - moveToMapPage: widget.moveToMapPage, - ); - }, - ), - ), - ), - ], - ), - ), - ), - ); - } - - @override - bool get wantKeepAlive => true; -} diff --git a/example/lib/screens/main/pages/downloading/state/downloading_provider.dart b/example/lib/screens/main/pages/downloading/state/downloading_provider.dart deleted file mode 100644 index 32009d60..00000000 --- a/example/lib/screens/main/pages/downloading/state/downloading_provider.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../../../../shared/misc/circular_buffer.dart'; - -class DownloadingProvider extends ChangeNotifier { - Stream? _downloadProgress; - Stream? get downloadProgress => _downloadProgress; - void setDownloadProgress( - Stream? newStream, { - bool notify = true, - }) { - _downloadProgress = newStream; - if (notify) notifyListeners(); - } - - int _parallelThreads = 5; - int get parallelThreads => _parallelThreads; - set parallelThreads(int newNum) { - _parallelThreads = newNum; - notifyListeners(); - } - - int _bufferingAmount = 100; - int get bufferingAmount => _bufferingAmount; - set bufferingAmount(int newNum) { - _bufferingAmount = newNum; - notifyListeners(); - } - - bool _skipExistingTiles = true; - bool get skipExistingTiles => _skipExistingTiles; - set skipExistingTiles(bool newBool) { - _skipExistingTiles = newBool; - notifyListeners(); - } - - bool _skipSeaTiles = true; - bool get skipSeaTiles => _skipSeaTiles; - set skipSeaTiles(bool newBool) { - _skipSeaTiles = newBool; - notifyListeners(); - } - - int? _rateLimit = 200; - int? get rateLimit => _rateLimit; - set rateLimit(int? newNum) { - _rateLimit = newNum; - notifyListeners(); - } - - bool _disableRecovery = false; - bool get disableRecovery => _disableRecovery; - set disableRecovery(bool newBool) { - _disableRecovery = newBool; - notifyListeners(); - } - - bool _showQuitTilesPreviewIndicator = false; - bool get showQuitTilesPreviewIndicator => _showQuitTilesPreviewIndicator; - set showQuitTilesPreviewIndicator(bool newBool) { - _showQuitTilesPreviewIndicator = newBool; - notifyListeners(); - } - - StreamSubscription? _tilesPreviewStreamSub; - StreamSubscription? get tilesPreviewStreamSub => - _tilesPreviewStreamSub; - set tilesPreviewStreamSub( - StreamSubscription? newStreamSub, - ) { - _tilesPreviewStreamSub = newStreamSub; - notifyListeners(); - } - - final _tilesPreview = {}; - Map get tilesPreview => _tilesPreview; - void addTilePreview(TileCoordinates coords, Uint8List? image) { - _tilesPreview[coords] = image; - notifyListeners(); - } - - void clearTilesPreview() { - _tilesPreview.clear(); - notifyListeners(); - } - - final List _failedTiles = []; - List get failedTiles => _failedTiles; - void addFailedTile(TileEvent e) => _failedTiles.add(e); - - final CircularBuffer _skippedTiles = CircularBuffer(50); - CircularBuffer get skippedTiles => _skippedTiles; - void addSkippedTile(TileEvent e) => _skippedTiles.add(e); -} diff --git a/example/lib/screens/main/pages/map/components/bubble_arrow_painter.dart b/example/lib/screens/main/pages/map/components/bubble_arrow_painter.dart deleted file mode 100644 index 0491b3c7..00000000 --- a/example/lib/screens/main/pages/map/components/bubble_arrow_painter.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class BubbleArrowIndicator extends CustomPainter { - const BubbleArrowIndicator({ - this.borderRadius = BorderRadius.zero, - this.triangleSize = const Size(25, 10), - this.color, - }); - - final BorderRadius borderRadius; - final Size triangleSize; - final Color? color; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color ?? const Color(0xFF000000) - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.fill; - - canvas - ..drawPath( - Path() - ..moveTo(size.width / 2 - triangleSize.width / 2, size.height) - ..lineTo(size.width / 2, triangleSize.height + size.height) - ..lineTo(size.width / 2 + triangleSize.width / 2, size.height) - ..lineTo(size.width / 2 - triangleSize.width / 2, size.height), - paint, - ) - ..drawRRect( - borderRadius.toRRect(Rect.fromLTRB(0, 0, size.width, size.height)), - paint, - ); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} diff --git a/example/lib/screens/main/pages/map/components/download_progress_indicator.dart b/example/lib/screens/main/pages/map/components/download_progress_indicator.dart deleted file mode 100644 index 33d0a314..00000000 --- a/example/lib/screens/main/pages/map/components/download_progress_indicator.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../downloading/state/downloading_provider.dart'; -import 'bubble_arrow_painter.dart'; -import 'side_indicator_painter.dart'; - -class DownloadProgressIndicator extends StatelessWidget { - const DownloadProgressIndicator({ - super.key, - required this.constraints, - }); - - final BoxConstraints constraints; - - @override - Widget build(BuildContext context) { - final isNarrow = MediaQuery.sizeOf(context).width <= 950; - - return Selector?>( - selector: (context, provider) => provider.tilesPreviewStreamSub, - builder: (context, tpss, child) => isNarrow - ? AnimatedPositioned( - duration: const Duration(milliseconds: 1200), - curve: Curves.elasticOut, - bottom: tpss != null ? 20 : -55, - left: constraints.maxWidth / 2 + constraints.maxWidth / 8 - 85, - height: 50, - width: 170, - child: CustomPaint( - painter: BubbleArrowIndicator( - borderRadius: BorderRadius.circular(12), - color: Theme.of(context).colorScheme.surface, - ), - child: child, - ), - ) - : AnimatedPositioned( - duration: const Duration(milliseconds: 1200), - curve: Curves.elasticOut, - top: constraints.maxHeight / 2 + 12, - left: tpss != null ? 8 : -200, - height: 50, - width: 180, - child: CustomPaint( - painter: SideIndicatorPainter( - startRadius: const Radius.circular(8), - endRadius: const Radius.circular(25), - color: Theme.of(context).colorScheme.surface, - ), - child: Padding( - padding: const EdgeInsets.only(left: 20), - child: child, - ), - ), - ), - child: StreamBuilder( - stream: context.select?>( - (provider) => provider.downloadProgress, - ), - builder: (context, snapshot) { - if (snapshot.data == null) { - return const Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator.adaptive(), - ), - ); - } - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${snapshot.data!.percentageProgress.toStringAsFixed(0)}%', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22, - color: Colors.white, - ), - ), - const SizedBox.square(dimension: 12), - Text( - '${snapshot.data!.tilesPerSecond.toStringAsPrecision(3)} tps', - style: const TextStyle( - fontSize: 20, - color: Colors.white, - ), - ), - ], - ); - }, - ), - ); - } -} diff --git a/example/lib/screens/main/pages/map/components/empty_tile_provider.dart b/example/lib/screens/main/pages/map/components/empty_tile_provider.dart deleted file mode 100644 index 9bc23269..00000000 --- a/example/lib/screens/main/pages/map/components/empty_tile_provider.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; - -class EmptyTileProvider extends TileProvider { - @override - ImageProvider getImage( - TileCoordinates coordinates, - TileLayer options, - ) => - MemoryImage(TileProvider.transparentImage); -} diff --git a/example/lib/screens/main/pages/map/components/map_view.dart b/example/lib/screens/main/pages/map/components/map_view.dart deleted file mode 100644 index af3e2d1f..00000000 --- a/example/lib/screens/main/pages/map/components/map_view.dart +++ /dev/null @@ -1,104 +0,0 @@ -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'; -import 'package:latlong2/latlong.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../shared/components/build_attribution.dart'; -import '../../../../../shared/components/loading_indicator.dart'; -import '../../../../../shared/state/general_provider.dart'; -import '../../downloading/state/downloading_provider.dart'; -import '../../region_selection/components/region_shape.dart'; -import '../state/map_provider.dart'; -import 'empty_tile_provider.dart'; - -class MapView extends StatelessWidget { - const MapView({super.key}); - - @override - Widget build(BuildContext context) => Selector( - selector: (context, provider) => provider.currentStore, - builder: (context, currentStore, _) => - FutureBuilder?>( - future: currentStore == null - ? Future.sync(() => {}) - : FMTCStore(currentStore).metadata.read, - builder: (context, metadata) { - if (!metadata.hasData || - metadata.data == null || - (currentStore != null && metadata.data!.isEmpty)) { - return const LoadingIndicator('Preparing Map'); - } - - final urlTemplate = currentStore != null && metadata.data != null - ? metadata.data!['sourceURL']! - : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; - - return FlutterMap( - mapController: Provider.of(context).mapController, - options: const MapOptions( - initialCenter: LatLng(51.509364, -0.128928), - initialZoom: 12, - interactionOptions: InteractionOptions( - flags: InteractiveFlag.all & ~InteractiveFlag.rotate, - scrollWheelVelocity: 0.002, - ), - keepAlive: true, - backgroundColor: Color(0xFFaad3df), - ), - children: [ - if (context.select?>( - (provider) => provider.tilesPreviewStreamSub, - ) == - null) - TileLayer( - urlTemplate: urlTemplate, - userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', - maxNativeZoom: 20, - tileProvider: currentStore != null - ? FMTCStore(currentStore).getTileProvider( - settings: FMTCTileProviderSettings( - behavior: CacheBehavior.values - .byName(metadata.data!['behaviour']!), - cachedValidDuration: int.parse( - metadata.data!['validDuration']!, - ) == - 0 - ? Duration.zero - : Duration( - days: int.parse( - metadata.data!['validDuration']!, - ), - ), - /*maxStoreLength: - int.parse(metadata.data!['maxLength']!),*/ - ), - ) - : NetworkTileProvider(), - ) - else ...[ - const SizedBox.expand( - child: ColoredBox(color: Colors.grey), - ), - TileLayer( - tileBuilder: (context, widget, tile) { - final bytes = context - .read() - .tilesPreview[tile.coordinates]; - if (bytes == null) return const SizedBox.shrink(); - return Image.memory(bytes); - }, - tileProvider: EmptyTileProvider(), - ), - const RegionShape(), - ], - StandardAttribution(urlTemplate: urlTemplate), - ], - ); - }, - ), - ); -} diff --git a/example/lib/screens/main/pages/map/components/quit_tiles_preview_indicator.dart b/example/lib/screens/main/pages/map/components/quit_tiles_preview_indicator.dart deleted file mode 100644 index 6ac0c028..00000000 --- a/example/lib/screens/main/pages/map/components/quit_tiles_preview_indicator.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:provider/provider.dart'; - -import '../../downloading/state/downloading_provider.dart'; -import 'side_indicator_painter.dart'; - -class QuitTilesPreviewIndicator extends StatelessWidget { - const QuitTilesPreviewIndicator({ - super.key, - required this.constraints, - }); - - final BoxConstraints constraints; - - @override - Widget build(BuildContext context) { - final isNarrow = MediaQuery.sizeOf(context).width <= 950; - - return Selector( - selector: (context, provider) => provider.showQuitTilesPreviewIndicator, - builder: (context, sqtpi, child) => AnimatedPositioned( - duration: const Duration(milliseconds: 1200), - curve: Curves.elasticOut, - top: isNarrow ? null : constraints.maxHeight / 2 - 139, - left: isNarrow - ? constraints.maxWidth / 2 - - 55 - - constraints.maxWidth / 4 - - constraints.maxWidth / 8 - : sqtpi - ? 8 - : -120, - bottom: isNarrow - ? sqtpi - ? 38 - : -90 - : null, - height: 50, - width: 110, - child: child!, - ), - child: Transform.rotate( - angle: isNarrow ? 270 * pi / 180 : 0, - child: CustomPaint( - painter: SideIndicatorPainter( - startRadius: const Radius.circular(8), - endRadius: const Radius.circular(25), - color: Theme.of(context).colorScheme.surface, - ), - child: Padding( - padding: const EdgeInsets.only(left: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - RotatedBox( - quarterTurns: isNarrow ? 1 : 0, - child: const Icon(Icons.touch_app, size: 32), - ), - const SizedBox.square(dimension: 6), - RotatedBox( - quarterTurns: isNarrow ? 1 : 0, - child: const Icon(Icons.visibility_off, size: 32), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/example/lib/screens/main/pages/map/components/side_indicator_painter.dart b/example/lib/screens/main/pages/map/components/side_indicator_painter.dart deleted file mode 100644 index 399b0857..00000000 --- a/example/lib/screens/main/pages/map/components/side_indicator_painter.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class SideIndicatorPainter extends CustomPainter { - const SideIndicatorPainter({ - this.startRadius = Radius.zero, - this.endRadius = Radius.zero, - this.color, - }); - - final Radius startRadius; - final Radius endRadius; - final Color? color; - - @override - void paint(Canvas canvas, Size size) => canvas.drawPath( - Path() - ..moveTo(0, size.height / 2) - ..lineTo((size.height / 2) - startRadius.x, startRadius.y) - ..quadraticBezierTo( - size.height / 2, - 0, - (size.height / 2) + startRadius.x, - 0, - ) - ..lineTo(size.width - endRadius.x, 0) - ..arcToPoint( - Offset(size.width, endRadius.y), - radius: endRadius, - ) - ..lineTo(size.width, size.height - endRadius.y) - ..arcToPoint( - Offset(size.width - endRadius.x, size.height), - radius: endRadius, - ) - ..lineTo((size.height / 2) + startRadius.x, size.height) - ..quadraticBezierTo( - size.height / 2, - size.height, - (size.height / 2) - startRadius.x, - size.height - startRadius.y, - ) - ..lineTo(0, size.height / 2) - ..close(), - Paint() - ..color = color ?? const Color(0xFF000000) - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.fill, - ); - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} diff --git a/example/lib/screens/main/pages/map/map_page.dart b/example/lib/screens/main/pages/map/map_page.dart deleted file mode 100644 index 94114889..00000000 --- a/example/lib/screens/main/pages/map/map_page.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_animations/flutter_map_animations.dart'; -import 'package:provider/provider.dart'; - -import 'components/download_progress_indicator.dart'; -import 'components/map_view.dart'; -import 'components/quit_tiles_preview_indicator.dart'; -import 'state/map_provider.dart'; - -class MapPage extends StatefulWidget { - const MapPage({super.key}); - - @override - State createState() => _MapPageState(); -} - -class _MapPageState extends State with TickerProviderStateMixin { - late final _animatedMapController = AnimatedMapController( - vsync: this, - duration: const Duration(milliseconds: 80), - curve: Curves.linear, - ); - - @override - void initState() { - super.initState(); - - // Setup animated map controller - WidgetsBinding.instance.addPostFrameCallback( - (_) { - context.read() - ..mapController = _animatedMapController.mapController - ..animateTo = _animatedMapController.animateTo; - }, - ); - } - - @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) => Stack( - children: [ - const MapView(), - QuitTilesPreviewIndicator(constraints: constraints), - DownloadProgressIndicator(constraints: constraints), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/recovery/components/empty_indicator.dart b/example/lib/screens/main/pages/recovery/components/empty_indicator.dart deleted file mode 100644 index 245ec722..00000000 --- a/example/lib/screens/main/pages/recovery/components/empty_indicator.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; - -class EmptyIndicator extends StatelessWidget { - const EmptyIndicator({ - super.key, - }); - - @override - Widget build(BuildContext context) => const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.done, size: 38), - SizedBox(height: 10), - Text('No Recoverable Regions Found'), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/recovery/components/header.dart b/example/lib/screens/main/pages/recovery/components/header.dart deleted file mode 100644 index a5bd75cf..00000000 --- a/example/lib/screens/main/pages/recovery/components/header.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -class Header extends StatelessWidget { - const Header({ - super.key, - }); - - @override - Widget build(BuildContext context) => Text( - 'Recovery', - style: GoogleFonts.ubuntu( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ); -} diff --git a/example/lib/screens/main/pages/recovery/components/recovery_list.dart b/example/lib/screens/main/pages/recovery/components/recovery_list.dart deleted file mode 100644 index 4c310fbc..00000000 --- a/example/lib/screens/main/pages/recovery/components/recovery_list.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:osm_nominatim/osm_nominatim.dart'; - -import 'recovery_start_button.dart'; - -class RecoveryList extends StatefulWidget { - const RecoveryList({ - super.key, - required this.all, - required this.moveToDownloadPage, - }); - - final Iterable<({bool isFailed, RecoveredRegion region})> all; - final void Function() moveToDownloadPage; - - @override - State createState() => _RecoveryListState(); -} - -class _RecoveryListState extends State { - @override - Widget build(BuildContext context) => ListView.separated( - itemCount: widget.all.length, - itemBuilder: (context, index) { - final result = widget.all.elementAt(index); - final region = result.region; - final isFailed = result.isFailed; - - return ListTile( - leading: Icon( - isFailed ? Icons.warning : Icons.pending_actions, - color: isFailed ? Colors.red : null, - ), - title: Text( - '${region.storeName} - ${switch (region.toRegion()) { - RectangleRegion() => 'Rectangle', - CircleRegion() => 'Circle', - LineRegion() => 'Line', - CustomPolygonRegion() => 'Custom Polygon', - }} Type', - ), - subtitle: FutureBuilder( - future: Nominatim.reverseSearch( - lat: region.center?.latitude ?? - region.bounds?.center.latitude ?? - region.line?[0].latitude, - lon: region.center?.longitude ?? - region.bounds?.center.longitude ?? - region.line?[0].longitude, - zoom: 10, - addressDetails: true, - ), - builder: (context, response) => Text( - 'Started at ${region.time} (~${DateTime.timestamp().difference(region.time).inMinutes} minutes ago)\nCompleted ${region.start - 1} of ${region.end}\n${response.hasData ? 'Center near ${response.data!.address!['postcode']}, ${response.data!.address!['country']}' : response.hasError ? 'Unable To Reverse Geocode Location' : 'Please Wait...'}', - ), - ), - isThreeLine: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.delete_forever, color: Colors.red), - onPressed: () async { - await FMTCRoot.recovery.cancel(region.id); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Deleted Recovery Information'), - ), - ); - }, - ), - const SizedBox(width: 10), - RecoveryStartButton( - moveToDownloadPage: widget.moveToDownloadPage, - result: result, - ), - ], - ), - ); - }, - separatorBuilder: (context, index) => const Divider(), - ); -} diff --git a/example/lib/screens/main/pages/recovery/components/recovery_start_button.dart b/example/lib/screens/main/pages/recovery/components/recovery_start_button.dart deleted file mode 100644 index b7045901..00000000 --- a/example/lib/screens/main/pages/recovery/components/recovery_start_button.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../../../configure_download/configure_download.dart'; -import '../../region_selection/state/region_selection_provider.dart'; - -class RecoveryStartButton extends StatelessWidget { - const RecoveryStartButton({ - super.key, - required this.moveToDownloadPage, - required this.result, - }); - - final void Function() moveToDownloadPage; - final ({bool isFailed, RecoveredRegion region}) result; - - @override - Widget build(BuildContext context) => IconButton( - icon: Icon( - Icons.download, - color: result.isFailed ? Colors.green : null, - ), - onPressed: !result.isFailed - ? null - : () async { - final regionSelectionProvider = - Provider.of(context, listen: false) - ..region = result.region.toRegion() - ..minZoom = result.region.minZoom - ..maxZoom = result.region.maxZoom - ..setSelectedStore( - FMTCStore(result.region.storeName), - ); - - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ConfigureDownloadPopup( - region: regionSelectionProvider.region!, - minZoom: result.region.minZoom, - maxZoom: result.region.maxZoom, - startTile: result.region.start, - endTile: result.region.end, - ), - fullscreenDialog: true, - ), - ); - - moveToDownloadPage(); - }, - ); -} diff --git a/example/lib/screens/main/pages/recovery/recovery.dart b/example/lib/screens/main/pages/recovery/recovery.dart deleted file mode 100644 index 81b97607..00000000 --- a/example/lib/screens/main/pages/recovery/recovery.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../../../shared/components/loading_indicator.dart'; -import 'components/empty_indicator.dart'; -import 'components/header.dart'; -import 'components/recovery_list.dart'; - -class RecoveryPage extends StatefulWidget { - const RecoveryPage({ - super.key, - required this.moveToDownloadPage, - }); - - final void Function() moveToDownloadPage; - - @override - State createState() => _RecoveryPageState(); -} - -class _RecoveryPageState extends State { - late Future> - _recoverableRegions; - - @override - void initState() { - super.initState(); - - void listRecoverableRegions() => - _recoverableRegions = FMTCRoot.recovery.recoverableRegions; - - listRecoverableRegions(); - FMTCRoot.stats.watchRecovery().listen((_) { - if (mounted) { - listRecoverableRegions(); - setState(() {}); - } - }); - } - - @override - Widget build(BuildContext context) => Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Header(), - const SizedBox(height: 12), - Expanded( - child: FutureBuilder( - future: _recoverableRegions, - builder: (context, all) => all.hasData - ? all.data!.isEmpty - ? const EmptyIndicator() - : RecoveryList( - all: all.data!, - moveToDownloadPage: widget.moveToDownloadPage, - ) - : const LoadingIndicator( - 'Retrieving Recoverable Downloads', - ), - ), - ), - ], - ), - ), - ), - ); -} diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/parent.dart b/example/lib/screens/main/pages/region_selection/components/side_panel/parent.dart deleted file mode 100644 index 6024fe60..00000000 --- a/example/lib/screens/main/pages/region_selection/components/side_panel/parent.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:gpx/gpx.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../../shared/misc/exts/interleave.dart'; -import '../../../../../../shared/misc/region_selection_method.dart'; -import '../../../../../../shared/misc/region_type.dart'; -import '../../state/region_selection_provider.dart'; - -part 'additional_panes/additional_pane.dart'; -part 'additional_panes/adjust_zoom_lvls_pane.dart'; -part 'additional_panes/line_region_pane.dart'; -part 'additional_panes/slider_panel_base.dart'; -part 'custom_slider_track_shape.dart'; -part 'primary_pane.dart'; -part 'region_shape_button.dart'; - -class SidePanel extends StatelessWidget { - SidePanel({ - super.key, - required this.constraints, - required this.pushToConfigureDownload, - }) : layoutDirection = - constraints.maxWidth > 850 ? Axis.vertical : Axis.horizontal; - - final BoxConstraints constraints; - final void Function() pushToConfigureDownload; - - final Axis layoutDirection; - - @override - Widget build(BuildContext context) => PositionedDirectional( - top: layoutDirection == Axis.vertical ? 12 : null, - bottom: 12, - start: layoutDirection == Axis.vertical ? 24 : 12, - end: layoutDirection == Axis.vertical ? null : 12, - child: Center( - child: FittedBox( - child: layoutDirection == Axis.vertical - ? IntrinsicHeight( - child: _PrimaryPane( - constraints: constraints, - layoutDirection: layoutDirection, - pushToConfigureDownload: pushToConfigureDownload, - ), - ) - : IntrinsicWidth( - child: _PrimaryPane( - constraints: constraints, - layoutDirection: layoutDirection, - pushToConfigureDownload: pushToConfigureDownload, - ), - ), - ), - ), - ); -} diff --git a/example/lib/screens/main/pages/region_selection/region_selection.dart b/example/lib/screens/main/pages/region_selection/region_selection.dart deleted file mode 100644 index e11b9eca..00000000 --- a/example/lib/screens/main/pages/region_selection/region_selection.dart +++ /dev/null @@ -1,301 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:provider/provider.dart'; - -import '../../../../shared/components/build_attribution.dart'; -import '../../../../shared/components/loading_indicator.dart'; -import '../../../../shared/misc/region_selection_method.dart'; -import '../../../../shared/misc/region_type.dart'; -import '../../../../shared/state/general_provider.dart'; -import '../../../configure_download/configure_download.dart'; -import 'components/crosshairs.dart'; -import 'components/custom_polygon_snapping_indicator.dart'; -import 'components/region_shape.dart'; -import 'components/side_panel/parent.dart'; -import 'components/usage_instructions.dart'; -import 'state/region_selection_provider.dart'; - -class RegionSelectionPage extends StatefulWidget { - const RegionSelectionPage({super.key}); - - @override - State createState() => _RegionSelectionPageState(); -} - -class _RegionSelectionPageState extends State { - final mapController = MapController(); - - late final mapOptions = MapOptions( - initialCenter: const LatLng(51.509364, -0.128928), - initialZoom: 11, - interactionOptions: const InteractionOptions( - flags: InteractiveFlag.all & - ~InteractiveFlag.rotate & - ~InteractiveFlag.doubleTapZoom, - scrollWheelVelocity: 0.002, - ), - keepAlive: true, - backgroundColor: const Color(0xFFaad3df), - onTap: (_, __) { - final provider = context.read(); - - if (provider.isCustomPolygonComplete) return; - - final List coords; - if (provider.customPolygonSnap && - provider.regionType == RegionType.customPolygon) { - coords = provider.addCoordinate(provider.coordinates.first); - provider.customPolygonSnap = false; - } else { - coords = provider.addCoordinate(provider.currentNewPointPos); - } - - if (coords.length < 2) return; - - switch (provider.regionType) { - case RegionType.square: - if (coords.length == 2) { - provider.region = RectangleRegion(LatLngBounds.fromPoints(coords)); - break; - } - provider - ..clearCoordinates() - ..addCoordinate(provider.currentNewPointPos); - case RegionType.circle: - if (coords.length == 2) { - provider.region = CircleRegion( - coords[0], - const Distance(roundResult: false) - .distance(coords[0], coords[1]) / - 1000, - ); - break; - } - provider - ..clearCoordinates() - ..addCoordinate(provider.currentNewPointPos); - case RegionType.line: - provider.region = LineRegion(coords, provider.lineRadius); - case RegionType.customPolygon: - if (!provider.isCustomPolygonComplete) break; - provider.region = CustomPolygonRegion(coords); - } - }, - onSecondaryTap: (_, __) => - context.read().removeLastCoordinate(), - onLongPress: (_, __) => - context.read().removeLastCoordinate(), - onPointerHover: (evt, point) { - final provider = context.read(); - - if (provider.regionSelectionMethod == RegionSelectionMethod.usePointer) { - provider.currentNewPointPos = point; - - if (provider.regionType == RegionType.customPolygon) { - final coords = provider.coordinates; - if (coords.length > 1) { - final newPointPos = mapController.camera - .latLngToScreenPoint(coords.first) - .toOffset(); - provider.customPolygonSnap = coords.first != coords.last && - sqrt( - pow(newPointPos.dx - evt.localPosition.dx, 2) + - pow(newPointPos.dy - evt.localPosition.dy, 2), - ) < - 15; - } - } - } - }, - onPositionChanged: (position, _) { - final provider = context.read(); - - if (provider.regionSelectionMethod == - RegionSelectionMethod.useMapCenter) { - provider.currentNewPointPos = position.center; - - if (provider.regionType == RegionType.customPolygon) { - final coords = provider.coordinates; - if (coords.length > 1) { - final newPointPos = mapController.camera - .latLngToScreenPoint(coords.first) - .toOffset(); - final centerPos = mapController.camera - .latLngToScreenPoint(provider.currentNewPointPos) - .toOffset(); - provider.customPolygonSnap = coords.first != coords.last && - sqrt( - pow(newPointPos.dx - centerPos.dx, 2) + - pow(newPointPos.dy - centerPos.dy, 2), - ) < - 30; - } - } - } - }, - ); - - bool keyboardHandler(KeyEvent event) { - if (event is! KeyDownEvent) return false; - - final provider = context.read(); - - if (provider.region != null && - event.logicalKey == LogicalKeyboardKey.enter) { - pushToConfigureDownload(); - } else if (event.logicalKey == LogicalKeyboardKey.escape || - event.logicalKey == LogicalKeyboardKey.delete) { - provider.clearCoordinates(); - } else if (event.logicalKey == LogicalKeyboardKey.backspace) { - provider.removeLastCoordinate(); - } else if (provider.regionType != RegionType.square && - event.logicalKey == LogicalKeyboardKey.keyZ) { - provider - ..regionType = RegionType.square - ..clearCoordinates(); - } else if (provider.regionType != RegionType.circle && - event.logicalKey == LogicalKeyboardKey.keyX) { - provider - ..regionType = RegionType.circle - ..clearCoordinates(); - } else if (provider.regionType != RegionType.line && - event.logicalKey == LogicalKeyboardKey.keyC) { - provider - ..regionType = RegionType.line - ..clearCoordinates(); - } else if (provider.regionType != RegionType.customPolygon && - event.logicalKey == LogicalKeyboardKey.keyV) { - provider - ..regionType = RegionType.customPolygon - ..clearCoordinates(); - } - - return false; - } - - void pushToConfigureDownload() { - final provider = context.read(); - ServicesBinding.instance.keyboard.removeHandler(keyboardHandler); - Navigator.of(context) - .push( - MaterialPageRoute( - builder: (context) => ConfigureDownloadPopup( - region: provider.region!, - minZoom: provider.minZoom, - maxZoom: provider.maxZoom, - startTile: provider.startTile, - endTile: provider.endTile, - ), - fullscreenDialog: true, - ), - ) - .then( - (_) => ServicesBinding.instance.keyboard.addHandler(keyboardHandler), - ); - } - - @override - void initState() { - super.initState(); - ServicesBinding.instance.keyboard.addHandler(keyboardHandler); - } - - @override - void dispose() { - ServicesBinding.instance.keyboard.removeHandler(keyboardHandler); - super.dispose(); - } - - @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) => Stack( - children: [ - Selector( - selector: (context, provider) => provider.currentStore, - builder: (context, currentStore, _) => - FutureBuilder?>( - future: currentStore == null - ? Future.value() - : FMTCStore(currentStore).metadata.read, - builder: (context, metadata) { - if (currentStore != null && metadata.data == null) { - return const LoadingIndicator('Preparing Map'); - } - - final urlTemplate = metadata.data?['sourceURL'] ?? - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; - - return MouseRegion( - opaque: false, - cursor: context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter - ? MouseCursor.defer - : context.select( - (p) => p.customPolygonSnap, - ) - ? SystemMouseCursors.none - : SystemMouseCursors.precise, - child: FlutterMap( - mapController: mapController, - options: mapOptions, - children: [ - TileLayer( - urlTemplate: urlTemplate, - userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', - maxNativeZoom: 20, - tileBuilder: (context, widget, tile) => - FutureBuilder( - future: currentStore == null - ? Future.value() - : FMTCStore(currentStore) - .getTileProvider() - .checkTileCached( - coords: tile.coordinates, - options: - TileLayer(urlTemplate: urlTemplate), - ), - builder: (context, snapshot) => DecoratedBox( - position: DecorationPosition.foreground, - decoration: BoxDecoration( - color: (snapshot.data ?? false) - ? Colors.deepOrange.withOpacity(1 / 3) - : Colors.transparent, - ), - child: widget, - ), - ), - ), - const RegionShape(), - const CustomPolygonSnappingIndicator(), - StandardAttribution(urlTemplate: urlTemplate), - ], - ), - ); - }, - ), - ), - SidePanel( - constraints: constraints, - pushToConfigureDownload: pushToConfigureDownload, - ), - if (context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter && - !context.select( - (p) => p.customPolygonSnap, - )) - const Center(child: Crosshairs()), - UsageInstructions(constraints: constraints), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/stores/components/empty_indicator.dart b/example/lib/screens/main/pages/stores/components/empty_indicator.dart deleted file mode 100644 index c15e7ad0..00000000 --- a/example/lib/screens/main/pages/stores/components/empty_indicator.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; - -class EmptyIndicator extends StatelessWidget { - const EmptyIndicator({ - super.key, - }); - - @override - Widget build(BuildContext context) => const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.folder_off, size: 36), - SizedBox(height: 10), - Text('Get started by creating a store!'), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/stores/components/header.dart b/example/lib/screens/main/pages/stores/components/header.dart deleted file mode 100644 index cb1877bc..00000000 --- a/example/lib/screens/main/pages/stores/components/header.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../shared/state/general_provider.dart'; - -class Header extends StatelessWidget { - const Header({ - super.key, - }); - - @override - Widget build(BuildContext context) => Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Stores', - style: GoogleFonts.ubuntu( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - Consumer( - builder: (context, provider, _) => - provider.currentStore == null - ? const Text('Caching Disabled') - : Text( - 'Current Store: ${provider.currentStore}', - overflow: TextOverflow.fade, - softWrap: false, - ), - ), - ], - ), - ), - const SizedBox(width: 15), - Consumer( - child: const Icon(Icons.cancel), - builder: (context, provider, child) => IconButton( - icon: child!, - tooltip: 'Disable Caching', - onPressed: provider.currentStore == null - ? null - : () { - provider - ..currentStore = null - ..resetMap(); - }, - ), - ), - ], - ); -} diff --git a/example/lib/screens/main/pages/stores/components/root_stats_pane.dart b/example/lib/screens/main/pages/stores/components/root_stats_pane.dart deleted file mode 100644 index 209ba452..00000000 --- a/example/lib/screens/main/pages/stores/components/root_stats_pane.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../../../../shared/components/loading_indicator.dart'; -import '../../../../../shared/misc/exts/size_formatter.dart'; -import '../components/stat_display.dart'; - -class RootStatsPane extends StatefulWidget { - const RootStatsPane({super.key}); - - @override - State createState() => _RootStatsPaneState(); -} - -class _RootStatsPaneState extends State { - late final watchStream = FMTCRoot.stats.watchStores(triggerImmediately: true); - - @override - Widget build(BuildContext context) => Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - margin: const EdgeInsets.symmetric(vertical: 16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSecondary, - borderRadius: BorderRadius.circular(16), - ), - child: StreamBuilder( - stream: watchStream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Padding( - padding: EdgeInsets.all(12), - child: LoadingIndicator('Retrieving Stores'), - ); - } - - return Wrap( - alignment: WrapAlignment.spaceEvenly, - runAlignment: WrapAlignment.spaceEvenly, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 20, - children: [ - FutureBuilder( - future: FMTCRoot.stats.length, - builder: (context, snapshot) => StatDisplay( - statistic: snapshot.data?.toString(), - description: 'total tiles', - ), - ), - FutureBuilder( - future: FMTCRoot.stats.size, - builder: (context, snapshot) => StatDisplay( - statistic: snapshot.data == null - ? null - : ((snapshot.data! * 1024).asReadableSize), - description: 'total tiles size', - ), - ), - FutureBuilder( - future: FMTCRoot.stats.realSize, - builder: (context, snapshot) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - StatDisplay( - statistic: snapshot.data == null - ? null - : ((snapshot.data! * 1024).asReadableSize), - description: 'database size', - ), - const SizedBox.square(dimension: 6), - IconButton( - icon: const Icon(Icons.help_outline), - onPressed: () => _showDatabaseSizeInfoDialog(context), - ), - ], - ), - ), - ], - ); - }, - ), - ); - - void _showDatabaseSizeInfoDialog(BuildContext context) { - showAdaptiveDialog( - context: context, - builder: (context) => AlertDialog.adaptive( - title: const Text('Database Size'), - content: const Text( - 'This measurement refers to the actual size of the database root ' - '(which may be a flat/file or another structure).\nIncludes database ' - 'overheads, and may not follow the total tiles size in a linear ' - 'relationship, or any relationship at all.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ); - } -} diff --git a/example/lib/screens/main/pages/stores/components/stat_display.dart b/example/lib/screens/main/pages/stores/components/stat_display.dart deleted file mode 100644 index 3a6b5941..00000000 --- a/example/lib/screens/main/pages/stores/components/stat_display.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -class StatDisplay extends StatelessWidget { - const StatDisplay({ - super.key, - required this.statistic, - required this.description, - }); - - final String? statistic; - final String description; - - @override - Widget build(BuildContext context) => Column( - children: [ - if (statistic == null) - const CircularProgressIndicator.adaptive() - else - Text( - statistic!, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - Text( - description, - style: const TextStyle( - fontSize: 16, - ), - ), - ], - ); -} diff --git a/example/lib/screens/main/pages/stores/components/store_tile.dart b/example/lib/screens/main/pages/stores/components/store_tile.dart deleted file mode 100644 index 83a88365..00000000 --- a/example/lib/screens/main/pages/stores/components/store_tile.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../shared/misc/exts/size_formatter.dart'; -import '../../../../../shared/state/general_provider.dart'; -import '../../../../store_editor/store_editor.dart'; -import 'stat_display.dart'; - -class StoreTile extends StatefulWidget { - StoreTile({ - required this.storeName, - }) : super(key: ValueKey(storeName)); - - final String storeName; - - @override - State createState() => _StoreTileState(); -} - -class _StoreTileState extends State { - bool _deletingProgress = false; - bool _emptyingProgress = false; - - @override - Widget build(BuildContext context) => Selector( - selector: (context, provider) => provider.currentStore, - builder: (context, currentStore, child) { - final store = FMTCStore(widget.storeName); - final isCurrentStore = currentStore == widget.storeName; - - return ExpansionTile( - title: Text( - widget.storeName, - style: TextStyle( - fontWeight: - isCurrentStore ? FontWeight.bold : FontWeight.normal, - ), - ), - subtitle: _deletingProgress ? const Text('Deleting...') : null, - initiallyExpanded: isCurrentStore, - children: [ - SizedBox( - width: double.infinity, - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: SizedBox( - width: double.infinity, - child: FutureBuilder( - future: store.manage.ready, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const UnconstrainedBox( - child: CircularProgressIndicator.adaptive(), - ); - } - - if (!snapshot.data!) { - return const Wrap( - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 24, - runSpacing: 12, - children: [ - Icon( - Icons.broken_image_rounded, - size: 38, - ), - Text( - 'Invalid/missing store', - style: TextStyle( - fontSize: 16, - ), - textAlign: TextAlign.center, - ), - ], - ); - } - - return FutureBuilder( - future: store.stats.all, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const UnconstrainedBox( - child: - CircularProgressIndicator.adaptive(), - ); - } - - return Wrap( - alignment: WrapAlignment.spaceEvenly, - runAlignment: WrapAlignment.spaceEvenly, - crossAxisAlignment: - WrapCrossAlignment.center, - spacing: 32, - runSpacing: 16, - children: [ - SizedBox.square( - dimension: 160, - child: ClipRRect( - borderRadius: - BorderRadius.circular(16), - child: FutureBuilder( - future: store.stats.tileImage( - gaplessPlayback: true, - ), - builder: (context, snapshot) { - if (snapshot.connectionState != - ConnectionState.done) { - return const UnconstrainedBox( - child: - CircularProgressIndicator - .adaptive(), - ); - } - - if (snapshot.data == null) { - return const Icon( - Icons.grid_view_rounded, - size: 38, - ); - } - - return snapshot.data!; - }, - ), - ), - ), - Wrap( - alignment: WrapAlignment.spaceEvenly, - runAlignment: WrapAlignment.spaceEvenly, - crossAxisAlignment: - WrapCrossAlignment.center, - spacing: 64, - children: [ - StatDisplay( - statistic: snapshot.data!.length - .toString(), - description: 'tiles', - ), - StatDisplay( - statistic: - (snapshot.data!.size * 1024) - .asReadableSize, - description: 'size', - ), - StatDisplay( - statistic: - snapshot.data!.hits.toString(), - description: 'hits', - ), - StatDisplay( - statistic: snapshot.data!.misses - .toString(), - description: 'misses', - ), - ], - ), - ], - ); - }, - ); - }, - ), - ), - ), - const SizedBox.square(dimension: 8), - Padding( - padding: const EdgeInsets.all(8), - child: SizedBox( - width: double.infinity, - child: Wrap( - alignment: WrapAlignment.spaceEvenly, - runAlignment: WrapAlignment.spaceEvenly, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 16, - children: [ - IconButton( - icon: _deletingProgress - ? const CircularProgressIndicator( - strokeWidth: 3, - ) - : Icon( - Icons.delete_forever, - color: - isCurrentStore ? null : Colors.red, - ), - tooltip: 'Delete Store', - onPressed: isCurrentStore || _deletingProgress - ? null - : () async { - setState(() { - _deletingProgress = true; - _emptyingProgress = true; - }); - await FMTCStore(widget.storeName) - .manage - .delete(); - }, - ), - IconButton( - icon: _emptyingProgress - ? const CircularProgressIndicator( - strokeWidth: 3, - ) - : const Icon(Icons.delete), - tooltip: 'Empty Store', - onPressed: _emptyingProgress - ? null - : () async { - setState( - () => _emptyingProgress = true, - ); - await FMTCStore(widget.storeName) - .manage - .reset(); - setState( - () => _emptyingProgress = false, - ); - }, - ), - IconButton( - icon: const Icon(Icons.edit), - tooltip: 'Edit Store', - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => - StoreEditorPopup( - existingStoreName: widget.storeName, - isStoreInUse: isCurrentStore, - ), - fullscreenDialog: true, - ), - ), - ), - IconButton( - icon: Icon( - Icons.done, - color: isCurrentStore ? Colors.green : null, - ), - tooltip: 'Use Store', - onPressed: isCurrentStore - ? null - : () { - context.read() - ..currentStore = widget.storeName - ..resetMap(); - }, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ], - ); - }, - ); -} diff --git a/example/lib/screens/main/pages/stores/stores.dart b/example/lib/screens/main/pages/stores/stores.dart deleted file mode 100644 index 4b2024c0..00000000 --- a/example/lib/screens/main/pages/stores/stores.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../../../shared/components/loading_indicator.dart'; -import '../../../export_import/export_import.dart'; -import '../../../store_editor/store_editor.dart'; -import 'components/empty_indicator.dart'; -import 'components/header.dart'; -import 'components/root_stats_pane.dart'; -import 'components/store_tile.dart'; - -class StoresPage extends StatefulWidget { - const StoresPage({super.key}); - - @override - State createState() => _StoresPageState(); -} - -class _StoresPageState extends State { - late final storesStream = FMTCRoot.stats - .watchStores(triggerImmediately: true) - .asyncMap((_) => FMTCRoot.stats.storesAvailable); - - @override - Widget build(BuildContext context) { - const loadingIndicator = LoadingIndicator('Retrieving Stores'); - - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - const Header(), - const SizedBox(height: 16), - Expanded( - child: StreamBuilder( - stream: storesStream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Padding( - padding: EdgeInsets.all(12), - child: loadingIndicator, - ); - } - - if (snapshot.data!.isEmpty) { - return const Column( - children: [ - RootStatsPane(), - Expanded(child: EmptyIndicator()), - ], - ); - } - - return ListView.builder( - itemCount: snapshot.data!.length + 2, - itemBuilder: (context, index) { - if (index == 0) { - return const RootStatsPane(); - } - - // Ensure the store buttons are not obscured by the FABs - if (index >= snapshot.data!.length + 1) { - return const SizedBox(height: 124); - } - - final storeName = - snapshot.data!.elementAt(index - 1).storeName; - return FutureBuilder( - future: FMTCStore(storeName).manage.ready, - builder: (context, snapshot) { - if (snapshot.data == null) { - return const SizedBox.shrink(); - } - - return StoreTile(storeName: storeName); - }, - ); - }, - ); - }, - ), - ), - ], - ), - ), - ), - floatingActionButton: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - FloatingActionButton.small( - heroTag: 'importExport', - tooltip: 'Export/Import', - shape: const CircleBorder(), - child: const Icon(Icons.folder_zip_rounded), - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const ExportImportPopup(), - fullscreenDialog: true, - ), - ), - ), - const SizedBox.square(dimension: 12), - FloatingActionButton.extended( - label: const Text('Create Store'), - icon: const Icon(Icons.create_new_folder_rounded), - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const StoreEditorPopup( - existingStoreName: null, - isStoreInUse: false, - ), - fullscreenDialog: true, - ), - ), - ), - ], - ), - ); - } -} diff --git a/example/lib/screens/store_editor/components/header.dart b/example/lib/screens/store_editor/components/header.dart deleted file mode 100644 index 8ce3704b..00000000 --- a/example/lib/screens/store_editor/components/header.dart +++ /dev/null @@ -1,101 +0,0 @@ -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/general_provider.dart'; -import '../store_editor.dart'; - -AppBar buildHeader({ - required StoreEditorPopup widget, - required bool mounted, - required GlobalKey formKey, - required Map newValues, - required bool useNewCacheModeValue, - required String? cacheModeValue, - required BuildContext context, -}) => - AppBar( - title: Text( - widget.existingStoreName == null - ? 'Create New Store' - : "Edit '${widget.existingStoreName}'", - ), - actions: [ - IconButton( - icon: Icon( - widget.existingStoreName == null ? Icons.save_as : Icons.save, - ), - onPressed: () async { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Saving...'), - duration: Duration(milliseconds: 1500), - ), - ); - - // Give the asynchronus validation a chance - await Future.delayed(const Duration(seconds: 1)); - if (!mounted) return; - - if (formKey.currentState!.validate()) { - formKey.currentState!.save(); - - final existingStore = widget.existingStoreName == null - ? null - : FMTCStore(widget.existingStoreName!); - final newStore = existingStore == null - ? FMTCStore(newValues['storeName']!) - : await existingStore.manage.rename(newValues['storeName']!); - if (!mounted) return; - - /*final downloadProvider = - Provider.of(context, listen: false); - if (existingStore != null && - downloadProvider.selectedStore == existingStore) { - downloadProvider.setSelectedStore(newStore); - }*/ - - if (existingStore == null) await newStore.manage.create(); - - // Designed to test both methods, even though only bulk would be - // more efficient - await newStore.metadata.set( - key: 'sourceURL', - value: newValues['sourceURL']!, - ); - await newStore.metadata.setBulk( - kvs: { - 'validDuration': newValues['validDuration']!, - 'maxLength': newValues['maxLength']!, - if (widget.existingStoreName == null || useNewCacheModeValue) - 'behaviour': cacheModeValue ?? 'cacheFirst', - }, - ); - - if (!context.mounted) return; - if (widget.isStoreInUse && widget.existingStoreName != null) { - Provider.of(context, listen: false) - .currentStore = newValues['storeName']; - } - Navigator.of(context).pop(); - - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Saved successfully')), - ); - } else { - if (!context.mounted) return; - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Please correct the appropriate fields', - ), - ), - ); - } - }, - ), - ], - ); diff --git a/example/lib/screens/store_editor/store_editor.dart b/example/lib/screens/store_editor/store_editor.dart deleted file mode 100644 index fe404d9b..00000000 --- a/example/lib/screens/store_editor/store_editor.dart +++ /dev/null @@ -1,262 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:http/http.dart' as http; -import 'package:provider/provider.dart'; -import 'package:validators/validators.dart' as validators; - -import '../../shared/components/loading_indicator.dart'; -import '../../shared/state/general_provider.dart'; -import '../main/pages/region_selection/state/region_selection_provider.dart'; -import 'components/header.dart'; - -class StoreEditorPopup extends StatefulWidget { - const StoreEditorPopup({ - super.key, - required this.existingStoreName, - required this.isStoreInUse, - }); - - final String? existingStoreName; - final bool isStoreInUse; - - @override - State createState() => _StoreEditorPopupState(); -} - -class _StoreEditorPopupState extends State { - final _formKey = GlobalKey(); - final Map _newValues = {}; - - String? _httpRequestFailed; - bool _storeNameIsDuplicate = false; - - bool _useNewCacheModeValue = false; - String? _cacheModeValue; - - late final ScaffoldMessengerState scaffoldMessenger; - - @override - void didChangeDependencies() { - scaffoldMessenger = ScaffoldMessenger.of(context); - super.didChangeDependencies(); - } - - @override - Widget build(BuildContext context) => Consumer( - builder: (context, downloadProvider, _) => Scaffold( - appBar: buildHeader( - widget: widget, - mounted: mounted, - formKey: _formKey, - newValues: _newValues, - useNewCacheModeValue: _useNewCacheModeValue, - cacheModeValue: _cacheModeValue, - context: context, - ), - body: Consumer( - builder: (context, provider, _) => Padding( - padding: const EdgeInsets.all(12), - child: FutureBuilder?>( - future: widget.existingStoreName == null - ? Future.sync(() => {}) - : FMTCStore(widget.existingStoreName!).metadata.read, - builder: (context, metadata) { - if (!metadata.hasData || metadata.data == null) { - return const LoadingIndicator('Retrieving Settings'); - } - return Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - children: [ - TextFormField( - decoration: const InputDecoration( - labelText: 'Store Name', - prefixIcon: Icon(Icons.text_fields), - isDense: true, - ), - onChanged: (input) async { - _storeNameIsDuplicate = - (await FMTCRoot.stats.storesAvailable) - .contains(FMTCStore(input)); - setState(() {}); - }, - validator: (input) => input == null || input.isEmpty - ? 'Required' - : _storeNameIsDuplicate - ? 'Store already exists' - : null, - onSaved: (input) => - _newValues['storeName'] = input!, - autovalidateMode: - AutovalidateMode.onUserInteraction, - textCapitalization: TextCapitalization.words, - initialValue: widget.existingStoreName, - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 5), - TextFormField( - decoration: const InputDecoration( - labelText: 'Map Source URL', - helperText: - "Use '{x}', '{y}', '{z}' as placeholders. Include protocol. Omit subdomain.", - prefixIcon: Icon(Icons.link), - isDense: true, - ), - onChanged: (i) async { - final uri = Uri.tryParse( - NetworkTileProvider().getTileUrl( - const TileCoordinates(0, 0, 0), - TileLayer(urlTemplate: i), - ), - ); - - if (uri == null) { - setState( - () => _httpRequestFailed = 'Invalid URL', - ); - return; - } - - _httpRequestFailed = await http.get(uri).then( - (res) => res.statusCode == 200 - ? null - : 'HTTP Request Failed', - onError: (_) => 'HTTP Request Failed', - ); - setState(() {}); - }, - validator: (i) { - final String input = i ?? ''; - - if (!validators.isURL( - input, - protocols: ['http', 'https'], - requireProtocol: true, - )) { - return 'Invalid URL'; - } - if (!input.contains('{x}') || - !input.contains('{y}') || - !input.contains('{z}')) { - return 'Missing placeholder(s)'; - } - - return _httpRequestFailed; - }, - onSaved: (input) => - _newValues['sourceURL'] = input!, - autovalidateMode: - AutovalidateMode.onUserInteraction, - keyboardType: TextInputType.url, - initialValue: metadata.data!.isEmpty - ? 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' - : metadata.data!['sourceURL'], - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 5), - TextFormField( - decoration: const InputDecoration( - labelText: 'Valid Cache Duration', - helperText: 'Use 0 to disable expiry', - suffixText: 'days', - prefixIcon: Icon(Icons.timelapse), - isDense: true, - ), - validator: (input) { - if (input == null || - input.isEmpty || - int.parse(input) < 0) { - return 'Must be 0 or more'; - } - return null; - }, - onSaved: (input) => - _newValues['validDuration'] = input!, - autovalidateMode: - AutovalidateMode.onUserInteraction, - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - initialValue: metadata.data!.isEmpty - ? '14' - : metadata.data!['validDuration'], - textInputAction: TextInputAction.done, - ), - const SizedBox(height: 5), - TextFormField( - decoration: const InputDecoration( - labelText: 'Maximum Length', - helperText: 'Use 0 to disable limit', - suffixText: 'tiles', - prefixIcon: Icon(Icons.disc_full), - isDense: true, - ), - validator: (input) { - if (input == null || - input.isEmpty || - int.parse(input) < 0) { - return 'Must be 0 or more'; - } - return null; - }, - onSaved: (input) => - _newValues['maxLength'] = input!, - autovalidateMode: - AutovalidateMode.onUserInteraction, - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - initialValue: metadata.data!.isEmpty - ? '100000' - : metadata.data!['maxLength'], - textInputAction: TextInputAction.done, - ), - Row( - children: [ - const Text('Cache Behaviour:'), - const SizedBox(width: 10), - Expanded( - child: DropdownButton( - value: _useNewCacheModeValue - ? _cacheModeValue! - : metadata.data!.isEmpty - ? 'cacheFirst' - : metadata.data!['behaviour'], - onChanged: (newVal) => setState( - () { - _cacheModeValue = newVal ?? 'cacheFirst'; - _useNewCacheModeValue = true; - }, - ), - items: [ - 'cacheFirst', - 'onlineFirst', - 'cacheOnly', - ] - .map>( - (v) => DropdownMenuItem( - value: v, - child: Text(v), - ), - ) - .toList(), - ), - ), - ], - ), - ], - ), - ), - ); - }, - ), - ), - ), - ), - ); -} diff --git a/example/lib/shared/misc/circular_buffer.dart b/example/lib/shared/misc/circular_buffer.dart deleted file mode 100644 index 212ed111..00000000 --- a/example/lib/shared/misc/circular_buffer.dart +++ /dev/null @@ -1,61 +0,0 @@ -// Adapted from https://github.com/kranfix/dart-circularbuffer under MIT license - -import 'dart:collection'; - -class CircularBuffer with ListMixin { - CircularBuffer(this.capacity) - : assert(capacity > 1, 'CircularBuffer must have a positive capacity'), - _buf = []; - - final List _buf; - int _start = 0; - - final int capacity; - bool get isFilled => _buf.length == capacity; - bool get isUnfilled => _buf.length < capacity; - - @override - T operator [](int index) { - if (index >= 0 && index < _buf.length) { - return _buf[(_start + index) % _buf.length]; - } - throw RangeError.index(index, this); - } - - @override - void operator []=(int index, T value) { - if (index >= 0 && index < _buf.length) { - _buf[(_start + index) % _buf.length] = value; - } else { - throw RangeError.index(index, this); - } - } - - @override - void add(T element) { - if (isUnfilled) { - assert(_start == 0, 'Internal buffer grown from a bad state'); - _buf.add(element); - return; - } - - _buf[_start] = element; - _start++; - if (_start == capacity) { - _start = 0; - } - } - - @override - void clear() { - _start = 0; - _buf.clear(); - } - - @override - int get length => _buf.length; - - @override - set length(int newLength) => - throw UnsupportedError('Cannot resize a CircularBuffer.'); -} diff --git a/example/lib/shared/state/general_provider.dart b/example/lib/shared/state/general_provider.dart deleted file mode 100644 index d80018af..00000000 --- a/example/lib/shared/state/general_provider.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -class GeneralProvider extends ChangeNotifier { - String? _currentStore; - String? get currentStore => _currentStore; - set currentStore(String? newStore) { - _currentStore = newStore; - notifyListeners(); - } - - final StreamController resetController = StreamController.broadcast(); - void resetMap() => resetController.add(null); -} diff --git a/example/lib/src/screens/home/config_panel/components/stores_list.dart b/example/lib/src/screens/home/config_panel/components/stores_list.dart new file mode 100644 index 00000000..e2c0d17b --- /dev/null +++ b/example/lib/src/screens/home/config_panel/components/stores_list.dart @@ -0,0 +1,211 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../../../shared/misc/exts/size_formatter.dart'; +import '../../../../shared/state/general_provider.dart'; + +class StoresList extends StatefulWidget { + const StoresList({ + super.key, + }); + + @override + State createState() => _StoresListState(); +} + +class _StoresListState extends State { + late final storesStream = + FMTCRoot.stats.watchStores(triggerImmediately: true).asyncMap( + (_) async { + final stores = await FMTCRoot.stats.storesAvailable; + return HashMap.fromEntries( + stores.map( + (store) => MapEntry( + store, + ( + stats: store.stats.all, + metadata: store.metadata.read, + ), + ), + ), + ); + }, + ); + + @override + Widget build(BuildContext context) => StreamBuilder( + stream: storesStream, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ); + } + + final stores = snapshot.data!; + + if (stores.isEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.folder_off, size: 42), + const SizedBox(height: 12), + Text( + 'Homes for tiles', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + const Text( + 'Tiles belong to one or more stores, so create a store to ' + 'get started', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.create_new_folder), + label: const Text('Create new store'), + ), + ], + ), + ), + ), + ); + } + + return SliverList.builder( + itemCount: stores.length + 1, + itemBuilder: (context, index) { + if (index == stores.length) { + return Material( + color: Colors.transparent, + child: ListTile( + title: const Text('Create new store'), + onTap: () {}, + leading: const SizedBox.square( + dimension: 56, + child: Center(child: Icon(Icons.create_new_folder)), + ), + ), + ); + } + + final store = stores.keys.elementAt(index); + final stats = stores.values.elementAt(index).stats; + //final metadata = stores.values.elementAt(index).metadata; + + return Material( + color: Colors.transparent, + child: Consumer( + builder: (context, provider, _) { + final isSelected = + provider.currentStores.contains(store.storeName) && + provider.storesSelectionMode == false; + + return ListTile( + title: Text(store.storeName), + subtitle: FutureBuilder( + future: stats, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const Text('Loading stats...'); + } + + return Text( + '${snapshot.data!.size.asReadableSize} | ${snapshot.data!.length} tiles', + ); + }, + ), + leading: SizedBox.square( + dimension: 56, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: FutureBuilder( + future: store.stats.tileImage(size: 56), + builder: (context, snapshot) { + if (snapshot.data != null) { + return snapshot.data!; + } + return const ColoredBox(color: Colors.white); + }, + ), + ), + Center( + child: SizedBox.square( + dimension: 24, + child: AnimatedOpacity( + opacity: isSelected ? 1 : 0, + duration: const Duration(milliseconds: 100), + curve: isSelected + ? Curves.easeIn + : Curves.easeOut, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(99), + ), + child: const Center( + child: Icon( + Icons.check, + color: Colors.white, + size: 20, + ), + ), + ), + ), + ), + ), + ], + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () {}, + ), + ], + ), + onTap: provider.storesSelectionMode == false + ? () { + if (isSelected) { + context + .read() + .removeStore(store.storeName); + } else { + context + .read() + .addStore(store.storeName); + } + } + : null, + ); + }, + ), + ); + }, + ); + }, + ); +} diff --git a/example/lib/src/screens/home/config_panel/map_config.dart b/example/lib/src/screens/home/config_panel/map_config.dart new file mode 100644 index 00000000..840c58b7 --- /dev/null +++ b/example/lib/src/screens/home/config_panel/map_config.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart' hide BottomSheet; +import 'package:provider/provider.dart'; + +import '../../../shared/state/general_provider.dart'; +import 'components/stores_list.dart'; + +class MapConfig extends StatefulWidget { + const MapConfig({ + super.key, + this.controller, + this.leading = const [], + }); + + final ScrollController? controller; + final List leading; + + @override + State createState() => _MapConfigState(); +} + +class _MapConfigState extends State { + final urlTextController = TextEditingController( + text: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + + @override + Widget build(BuildContext context) => CustomScrollView( + controller: widget.controller, + slivers: [ + ...widget.leading, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + 'Configuration', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Selector( + selector: (context, provider) => provider.storesSelectionMode, + builder: (context, storesSelectionMode, _) => SegmentedButton( + segments: const [ + ButtonSegment( + value: null, + icon: Icon(Icons.deselect), + label: Text('Disabled'), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.select_all), + label: Text('Use All'), + ), + ButtonSegment( + value: false, + icon: Icon(Icons.highlight_alt), + label: Text('Manual'), + ), + ], + selected: {storesSelectionMode}, + onSelectionChanged: (value) => context + .read() + .storesSelectionMode = value.single, + style: const ButtonStyle( + visualDensity: VisualDensity.comfortable, + ), + ), + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 8)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: LayoutBuilder( + builder: (context, constraints) => SizedBox( + width: constraints.maxWidth, + child: DropdownMenu( + controller: urlTextController, + width: constraints.maxWidth, + enableFilter: true, + requestFocusOnTap: true, + leadingIcon: const Icon(Icons.link), + label: const Text('URL Template'), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + //contentPadding: EdgeInsets.symmetric(vertical: 5.0), + ), + /*onSelected: (String? urlTemplate) { + setState(() { + selectedIcon = icon; + }); + },*/ + dropdownMenuEntries: [ + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'b', + 'ab', + ] + .map( + (urlTemplate) => DropdownMenuEntry( + value: urlTemplate, + label: urlTemplate, + labelWidget: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(urlTemplate), + const Text( + 'Used by: x', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + ), + ) + .toList(), + ), + ), + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 12)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + 'Stores', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + const StoresList(), + ], + ); +} diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/bottom_sheet.dart b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/bottom_sheet.dart new file mode 100644 index 00000000..f3375ea5 --- /dev/null +++ b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/bottom_sheet.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +import '../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; +import 'components/scrollable_provider.dart'; + +class BottomSheet extends StatefulWidget { + const BottomSheet({ + super.key, + required this.controller, + required this.child, + }); + + final DraggableScrollableController controller; + final Widget child; + + static const topPadding = kMinInteractiveDimension / 1.5; + + @override + State createState() => _BottomSheetState(); +} + +class _BottomSheetState extends State { + @override + Widget build(BuildContext context) { + final screenTopPadding = + MediaQueryData.fromView(View.of(context)).padding.top; + + return LayoutBuilder( + builder: (context, constraints) => DraggableScrollableSheet( + initialChildSize: 0.3, + minChildSize: 0, + snap: true, + expand: false, + snapSizes: const [0.3], + controller: widget.controller, + builder: (context, innerController) => + DelayedControllerAttachmentBuilder( + listenable: widget.controller, + builder: (context, child) { + double radius = 18; + double calcHeight = 0; + + if (widget.controller.isAttached) { + final maxHeight = widget.controller.sizeToPixels(1); + + final oldValue = widget.controller.pixels; + final oldMax = maxHeight; + final oldMin = maxHeight - radius; + const newMax = 0.0; + final newMin = radius; + + radius = ((((oldValue - oldMin) * (newMax - newMin)) / + (oldMax - oldMin)) + + newMin) + .clamp(0, radius); + + calcHeight = screenTopPadding - + constraints.maxHeight + + widget.controller.pixels; + } + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(radius), + topRight: Radius.circular(radius), + ), + child: Column( + children: [ + DelayedControllerAttachmentBuilder( + listenable: innerController, + builder: (context, _) => SizedBox( + height: calcHeight.clamp(0, screenTopPadding), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + color: innerController.hasClients && + innerController.offset != 0 + ? Theme.of(context) + .colorScheme + .surfaceContainerLowest + : Theme.of(context).colorScheme.surfaceContainerLow, + ), + ), + ), + Expanded( + child: ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: child, + ), + ), + ], + ), + ); + }, + child: Stack( + children: [ + BottomSheetScrollableProvider( + innerScrollController: innerController, + child: widget.child, + ), + IgnorePointer( + child: DelayedControllerAttachmentBuilder( + listenable: widget.controller, + builder: (context, _) { + if (!widget.controller.isAttached) { + return const SizedBox.shrink(); + } + + final calcHeight = BottomSheet.topPadding - + (screenTopPadding - + constraints.maxHeight + + widget.controller.pixels); + + return SizedBox( + height: calcHeight.clamp(0, BottomSheet.topPadding), + width: constraints.maxWidth, + child: Semantics( + label: MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + container: true, + child: Center( + child: Container( + height: 4, + width: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withOpacity(0.4), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/scrollable_provider.dart b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/scrollable_provider.dart new file mode 100644 index 00000000..b6498b62 --- /dev/null +++ b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/scrollable_provider.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; + +class BottomSheetScrollableProvider extends InheritedWidget { + const BottomSheetScrollableProvider({ + super.key, + required super.child, + required this.innerScrollController, + }); + + final ScrollController innerScrollController; + + Widget build(BuildContext context) => child; + + static ScrollController innerScrollControllerOf(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType()! + .innerScrollController; + + @override + bool updateShouldNotify(covariant BottomSheetScrollableProvider oldWidget) => + oldWidget.innerScrollController != innerScrollController; +} diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/toolbar.dart b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/toolbar.dart new file mode 100644 index 00000000..77317921 --- /dev/null +++ b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/toolbar.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart' hide BottomSheet; + +import '../bottom_sheet.dart'; + +class BottomSheetToolbar extends StatelessWidget { + const BottomSheetToolbar({ + super.key, + required this.bottomSheetOuterController, + required this.action, + }); + + final DraggableScrollableController bottomSheetOuterController; + final Widget action; + + double _calcVisibility(double size, double newMax) => + ((((size - 0.3) * (newMax - 0)) / (0.85 - 0.3)) + 0).clamp(0, newMax); + + @override + Widget build(BuildContext context) => Column( + children: [ + AnimatedBuilder( + animation: bottomSheetOuterController, + builder: (context, child) => SizedBox( + height: BottomSheet.topPadding - + _calcVisibility(bottomSheetOuterController.size, 16), + ), + ), + AnimatedBuilder( + animation: bottomSheetOuterController, + builder: (context, child) { + final size = bottomSheetOuterController.size; + + return Padding( + padding: EdgeInsets.only(bottom: _calcVisibility(size, 8)), + child: Opacity( + opacity: _calcVisibility(size, 1), + child: SizedBox( + height: _calcVisibility(size, 50), + child: child, + ), + ), + ); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(), + action, + ], + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/components/tab_header.dart b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/components/tab_header.dart new file mode 100644 index 00000000..dc669f36 --- /dev/null +++ b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/components/tab_header.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import '../../../../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; +import '../../../components/scrollable_provider.dart'; + +class TabHeader extends StatelessWidget { + const TabHeader({ + super.key, + required this.bottomSheetOuterController, + }); + + final DraggableScrollableController bottomSheetOuterController; + + @override + Widget build(BuildContext context) { + final screenTopPadding = + MediaQueryData.fromView(View.of(context)).padding.top; + final innerScrollController = + BottomSheetScrollableProvider.innerScrollControllerOf(context); + + return SliverPersistentHeader( + pinned: true, + delegate: PersistentHeader( + child: DelayedControllerAttachmentBuilder( + listenable: bottomSheetOuterController, + builder: (context, _) { + if (!bottomSheetOuterController.isAttached) { + return const SizedBox.shrink(); + } + + final maxHeight = bottomSheetOuterController.sizeToPixels(1); + + final oldValue = bottomSheetOuterController.pixels; + final oldMax = maxHeight; + final oldMin = maxHeight - screenTopPadding; + + const maxMinimizeIndentButtonWidth = 40; + const maxMinimizeIndentSpacer = 16; + const minMinimizeIndent = 0; + + final double minimizeIndentButtonWidth = ((((oldValue - oldMin) * + (maxMinimizeIndentButtonWidth - + minMinimizeIndent)) / + (oldMax - oldMin)) + + minMinimizeIndent) + .clamp(0.0, 40); + + final double minimizeIndentSpacer = ((((oldValue - oldMin) * + (maxMinimizeIndentSpacer - minMinimizeIndent)) / + (oldMax - oldMin)) + + minMinimizeIndent) + .clamp(0.0, 16); + + return AnimatedBuilder( + animation: innerScrollController, + builder: (context, child) => AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + color: innerScrollController.offset != 0 + ? Theme.of(context).colorScheme.surfaceContainerLowest + : Theme.of(context).colorScheme.surfaceContainerLow, + child: child, + ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + SizedBox( + width: minimizeIndentButtonWidth, + child: ClipRRect( + child: IconButton( + onPressed: () { + bottomSheetOuterController.animateTo( + 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + BottomSheetScrollableProvider + .innerScrollControllerOf(context) + .animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }, + icon: const Icon(Icons.keyboard_arrow_down), + ), + ), + ), + SizedBox(width: minimizeIndentSpacer), + Text( + 'Stores & Config', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + IconButton( + onPressed: () {}, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.help_outline), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + +class PersistentHeader extends SliverPersistentHeaderDelegate { + const PersistentHeader({required this.child}); + + final Widget child; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) => + Align(child: child); + + @override + double get maxExtent => 60; + + @override + double get minExtent => 60; + + @override + bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; +} diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart new file mode 100644 index 00000000..f153678c --- /dev/null +++ b/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart' hide BottomSheet; + +import '../../../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; +import '../../../../map_config.dart'; +import '../../bottom_sheet.dart'; +import '../../components/scrollable_provider.dart'; +import 'components/tab_header.dart'; + +class StoresAndConfigureTab extends StatefulWidget { + const StoresAndConfigureTab({ + super.key, + required this.bottomSheetOuterController, + }); + + final DraggableScrollableController bottomSheetOuterController; + + @override + State createState() => _StoresAndConfigureTabState(); +} + +class _StoresAndConfigureTabState extends State { + final urlTextController = TextEditingController( + text: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + + @override + Widget build(BuildContext context) { + final screenTopPadding = + MediaQueryData.fromView(View.of(context)).padding.top; + + return MapConfig( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + leading: [ + SliverToBoxAdapter( + child: DelayedControllerAttachmentBuilder( + listenable: widget.bottomSheetOuterController, + builder: (context, _) { + if (!widget.bottomSheetOuterController.isAttached) { + return const SizedBox.shrink(); + } + + final maxHeight = + widget.bottomSheetOuterController.sizeToPixels(1); + + final oldValue = widget.bottomSheetOuterController.pixels; + final oldMax = maxHeight; + final oldMin = maxHeight - screenTopPadding; + + const maxTopPadding = 0.0; + const minTopPadding = BottomSheet.topPadding - 8; + + final double topPaddingHeight = + ((((oldValue - oldMin) * (maxTopPadding - minTopPadding)) / + (oldMax - oldMin)) + + minTopPadding) + .clamp(0.0, BottomSheet.topPadding - 8); + + return SizedBox(height: topPaddingHeight); + }, + ), + ), + TabHeader( + bottomSheetOuterController: widget.bottomSheetOuterController, + ), + ], + ); + } +} diff --git a/example/lib/src/screens/home/config_panel/wrappers/side_panel/side_panel.dart b/example/lib/src/screens/home/config_panel/wrappers/side_panel/side_panel.dart new file mode 100644 index 00000000..7c3ebb5a --- /dev/null +++ b/example/lib/src/screens/home/config_panel/wrappers/side_panel/side_panel.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../../map_config.dart'; + +class MapConfigSidePanel extends StatelessWidget { + const MapConfigSidePanel({ + super.key, + required this.selectedTab, + }); + + final int selectedTab; + + @override + Widget build(BuildContext context) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + transitionBuilder: (child, animation) => SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + child: child, + ), + child: selectedTab == 0 + ? Container( + margin: const EdgeInsets.only( + right: 16, + top: 16, + bottom: 16, + ), + width: 380, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surfaceContainer, + ), + child: MapConfig( + leading: [ + SliverPadding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 8, + ), + sliver: SliverToBoxAdapter( + child: Row( + children: [ + Text( + 'Stores & Config', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + IconButton( + onPressed: () {}, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.help_outline), + ), + ], + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ); +} diff --git a/example/lib/src/screens/home/home.dart b/example/lib/src/screens/home/home.dart new file mode 100644 index 00000000..554e742d --- /dev/null +++ b/example/lib/src/screens/home/home.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart' hide BottomSheet; + +import 'config_panel/wrappers/bottom_sheet/bottom_sheet.dart'; +import 'config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart'; +import 'config_panel/wrappers/side_panel/side_panel.dart'; +import 'map_view/bottom_sheet_wrapper.dart'; +import 'map_view/map_view.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + static const String route = '/'; + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final bottomSheetOuterController = DraggableScrollableController(); + + /*late final bottomSheetTabs = [ + StoresAndConfigureTab( + bottomSheetOuterController: bottomSheetOuterController, + ), + StatefulBuilder( + builder: (context, _) { + return CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + ); + }, + ), + StatefulBuilder( + builder: (context, _) { + return CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + ); + }, + ), + ];*/ + + int selectedTab = 0; + + @override + Widget build(BuildContext context) { + final mapMode = switch (selectedTab) { + 0 => MapViewMode.standard, + 1 => MapViewMode.regionSelect, + _ => throw UnimplementedError(), + }; + + 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: BottomSheet( + controller: bottomSheetOuterController, + child: SizedBox( + width: double.infinity, + child: StoresAndConfigureTab( + bottomSheetOuterController: bottomSheetOuterController, + ), + ), + ), + 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) { + if (i == 0) { + final requiresExpanding = + bottomSheetOuterController.size < 0.3; + + if (selectedTab != 0) { + setState(() => selectedTab = 0); + if (requiresExpanding) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => bottomSheetOuterController.animateTo( + 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ), + ); + } + } else { + setState(() => selectedTab = i); + WidgetsBinding.instance.addPostFrameCallback( + (_) => bottomSheetOuterController.animateTo( + requiresExpanding ? 0.3 : 0, + duration: const Duration(milliseconds: 200), + curve: + requiresExpanding ? Curves.easeOut : Curves.easeIn, + ), + ); + } + } else { + setState(() => selectedTab = i); + WidgetsBinding.instance.addPostFrameCallback( + (_) => bottomSheetOuterController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + ), + ); + } + }, + ), + ); + } + + return Scaffold( + body: Row( + children: [ + NavigationRail( + 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, + ), + ), + ), + onDestinationSelected: (i) => setState(() => selectedTab = i), + ), + MapConfigSidePanel(selectedTab: selectedTab), + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + child: MapView( + mode: mapMode, + layoutDirection: layoutDirection, + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/example/lib/src/screens/home/map_view/bottom_sheet_wrapper.dart b/example/lib/src/screens/home/map_view/bottom_sheet_wrapper.dart new file mode 100644 index 00000000..37535977 --- /dev/null +++ b/example/lib/src/screens/home/map_view/bottom_sheet_wrapper.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import '../../../shared/components/delayed_frame_attached_dependent_builder.dart'; + +import 'map_view.dart'; + +/// Wraps [MapView] with the necessary widgets to keep the map contents clear +/// of the bottom sheet +/// +/// Not suitable for use with screens wider than the max width of the bottom +/// sheet, nor where there is no bottom sheet in use. +class BottomSheetMapWrapper extends StatefulWidget { + const BottomSheetMapWrapper({ + super.key, + required this.bottomSheetOuterController, + this.mode = MapViewMode.standard, + required this.layoutDirection, + }); + + final DraggableScrollableController bottomSheetOuterController; + final MapViewMode mode; + final Axis layoutDirection; + + @override + State createState() => _BottomSheetMapWrapperState(); +} + +class _BottomSheetMapWrapperState extends State { + // Extend the map as little as possible overlapping the bottom sheet to ensure + // the background does not appear outside the bottom sheet radius but also + // to load as little extra tiles as possible. + static const _assumedBottomSheetCornerRadius = 18; + + @override + Widget build(BuildContext context) { + // Introduce padding at the top of the screen to ensure the map gets + // below the status bar/front-camera. + // Introduce padding at the bottom of the screen to ensure that the + // center of the map is affected by the bottom sheet, so the center + // is always in the 'visible' center. + final screenPaddingTop = MediaQuery.paddingOf(context).top; + + return DelayedControllerAttachmentBuilder( + listenable: widget.bottomSheetOuterController, + builder: (context, child) { + final isAttached = widget.bottomSheetOuterController.isAttached; + + return Padding( + padding: EdgeInsets.only( + bottom: isAttached + ? (widget.bottomSheetOuterController.pixels - + _assumedBottomSheetCornerRadius) + .clamp(0, double.nan) + : 200, + top: screenPaddingTop, + ), + child: child, + ); + }, + child: LayoutBuilder( + builder: (context, constraints) { + // Allow the map to overflow, so the center remains at the + // ('visible') center, but everything else is drawn over the + // padding we just introduced, to give a seamless effect without + // black background at the top behind the status bar. + // + // Technically, overflowing downwards isn't necessary, but we + // must to ensure the center remains at the 'visible' center. + final height = constraints.maxHeight + screenPaddingTop * 2; + + return OverflowBox( + maxHeight: height, + child: MapView( + mode: widget.mode, + layoutDirection: widget.layoutDirection, + bottomPaddingWrapperBuilder: (context, child) { + final useAssumedRadius = + !widget.bottomSheetOuterController.isAttached || + widget.bottomSheetOuterController.pixels > + _assumedBottomSheetCornerRadius; + + return Padding( + padding: EdgeInsets.only( + bottom: screenPaddingTop + + (useAssumedRadius + ? _assumedBottomSheetCornerRadius + : widget.bottomSheetOuterController.pixels), + ), + child: child, + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart new file mode 100644 index 00000000..bd74167e --- /dev/null +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -0,0 +1,390 @@ +import 'dart:async'; +import 'dart:math'; + +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/components/loading_indicator.dart'; +import '../../../shared/misc/shared_preferences.dart'; +import '../../../shared/state/general_provider.dart'; +import 'region_selection_components/crosshairs.dart'; +import 'region_selection_components/custom_polygon_snapping_indicator.dart'; +import 'region_selection_components/region_shape.dart'; +import 'region_selection_components/side_panel/parent.dart'; +import 'state/region_selection_provider.dart'; + +enum MapViewMode { + standard, + regionSelect, +} + +class MapView extends StatefulWidget { + const MapView({ + super.key, + this.mode = MapViewMode.standard, + this.bottomPaddingWrapperBuilder, + required this.layoutDirection, + }); + + final MapViewMode mode; + final Widget Function(BuildContext context, Widget child)? + bottomPaddingWrapperBuilder; + final Axis layoutDirection; + + static const animationDuration = Duration(milliseconds: 500); + static const animationCurve = Curves.easeInOut; + + @override + State createState() => _MapViewState(); +} + +class _MapViewState extends State + with TickerProviderStateMixin, WidgetsBindingObserver { + late final mapController = AnimatedMapController( + vsync: this, + curve: MapView.animationCurve, + // ignore: avoid_redundant_argument_values + duration: MapView.animationDuration, + ); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _setMapLocationCache(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _setMapLocationCache(); + } + } + + void _setMapLocationCache() { + sharedPrefs + ..setDouble( + SharedPrefsKeys.mapLocationLat.name, + mapController.mapController.camera.center.latitude, + ) + ..setDouble( + SharedPrefsKeys.mapLocationLng.name, + mapController.mapController.camera.center.longitude, + ) + ..setDouble( + SharedPrefsKeys.mapLocationZoom.name, + mapController.mapController.camera.zoom, + ); + } + + final _attributionLayer = RichAttributionWidget( + alignment: AttributionAlignment.bottomLeft, + popupInitialDisplayDuration: const Duration(seconds: 3), + popupBorderRadius: BorderRadius.circular(12), + attributions: [ + //TextSourceAttribution(Uri.parse(urlTemplate).host), + const TextSourceAttribution( + 'For demonstration purposes only', + prependCopyright: false, + textStyle: TextStyle(fontWeight: FontWeight.bold), + ), + const TextSourceAttribution( + 'Offline mapping made with FMTC', + prependCopyright: false, + textStyle: TextStyle(fontStyle: FontStyle.italic), + ), + LogoSourceAttribution( + Image.asset('assets/icons/ProjectIcon.png'), + tooltip: 'flutter_map_tile_caching', + ), + ], + ); + + @override + Widget build(BuildContext context) { + final mapOptions = MapOptions( + initialCenter: LatLng( + sharedPrefs.getDouble(SharedPrefsKeys.mapLocationLat.name) ?? 51.5216, + sharedPrefs.getDouble(SharedPrefsKeys.mapLocationLng.name) ?? -0.6780, + ), + initialZoom: sharedPrefs.getDouble( + SharedPrefsKeys.mapLocationZoom.name, + ) ?? + 12, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.all & + ~InteractiveFlag.rotate & + ~InteractiveFlag.doubleTapZoom, + scrollWheelVelocity: 0.002, + ), + keepAlive: true, + backgroundColor: const Color(0xFFaad3df), + onTap: (_, __) { + if (widget.mode != MapViewMode.regionSelect) return; + + final provider = context.read(); + + if (provider.isCustomPolygonComplete) return; + + final List coords; + if (provider.customPolygonSnap && + provider.regionType == RegionType.customPolygon) { + coords = provider.addCoordinate(provider.coordinates.first); + provider.customPolygonSnap = false; + } else { + coords = provider.addCoordinate(provider.currentNewPointPos); + } + + if (coords.length < 2) return; + + switch (provider.regionType) { + case RegionType.square: + if (coords.length == 2) { + provider.region = + RectangleRegion(LatLngBounds.fromPoints(coords)); + break; + } + provider + ..clearCoordinates() + ..addCoordinate(provider.currentNewPointPos); + case RegionType.circle: + if (coords.length == 2) { + provider.region = CircleRegion( + coords[0], + const Distance(roundResult: false) + .distance(coords[0], coords[1]) / + 1000, + ); + break; + } + provider + ..clearCoordinates() + ..addCoordinate(provider.currentNewPointPos); + case RegionType.line: + provider.region = LineRegion(coords, provider.lineRadius); + case RegionType.customPolygon: + if (!provider.isCustomPolygonComplete) break; + provider.region = CustomPolygonRegion(coords); + } + }, + onSecondaryTap: (_, __) { + if (widget.mode != MapViewMode.regionSelect) return; + context.read().removeLastCoordinate(); + }, + onLongPress: (_, __) { + if (widget.mode != MapViewMode.regionSelect) return; + context.read().removeLastCoordinate(); + }, + onPointerHover: (evt, point) { + if (widget.mode != MapViewMode.regionSelect) return; + + final provider = context.read(); + + if (provider.regionSelectionMethod == + RegionSelectionMethod.usePointer) { + provider.currentNewPointPos = point; + + if (provider.regionType == RegionType.customPolygon) { + final coords = provider.coordinates; + if (coords.length > 1) { + final newPointPos = mapController.mapController.camera + .latLngToScreenPoint(coords.first) + .toOffset(); + provider.customPolygonSnap = coords.first != coords.last && + sqrt( + pow(newPointPos.dx - evt.localPosition.dx, 2) + + pow(newPointPos.dy - evt.localPosition.dy, 2), + ) < + 15; + } + } + } + }, + onPositionChanged: (position, _) { + if (widget.mode != MapViewMode.regionSelect) return; + + final provider = context.read(); + + if (provider.regionSelectionMethod == + RegionSelectionMethod.useMapCenter) { + provider.currentNewPointPos = position.center; + + if (provider.regionType == RegionType.customPolygon) { + final coords = provider.coordinates; + if (coords.length > 1) { + final newPointPos = mapController.mapController.camera + .latLngToScreenPoint(coords.first) + .toOffset(); + final centerPos = mapController.mapController.camera + .latLngToScreenPoint(provider.currentNewPointPos) + .toOffset(); + provider.customPolygonSnap = coords.first != coords.last && + sqrt( + pow(newPointPos.dx - centerPos.dx, 2) + + pow(newPointPos.dy - centerPos.dy, 2), + ) < + 30; + } + } + } + }, + onMapReady: () { + /*context.read() + ..mapController = mapController.mapController + ..animateTo = mapController.animateTo;*/ + }, + ); + + return Selector>( + selector: (context, provider) => provider.currentStores, + builder: (context, currentStores, _) => + FutureBuilder?>( + future: currentStores.isEmpty + ? Future.sync(() => {}) + : FMTCStore(currentStores.first).metadata.read, + builder: (context, metadata) { + if (!metadata.hasData || + metadata.data == null || + (currentStores.isNotEmpty && metadata.data!.isEmpty)) { + return const LoadingIndicator('Preparing map'); + } + + final urlTemplate = currentStores.isNotEmpty && metadata.data != null + ? metadata.data!['sourceURL']! + : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + + final map = FlutterMap( + mapController: mapController.mapController, + options: mapOptions, + children: [ + TileLayer( + urlTemplate: urlTemplate, + userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', + maxNativeZoom: 20, + tileProvider: currentStores.isNotEmpty + ? FMTCStore(currentStores.first).getTileProvider( + settings: FMTCTileProviderSettings( + behavior: CacheBehavior.values + .byName(metadata.data!['behaviour']!), + cachedValidDuration: int.parse( + metadata.data!['validDuration']!, + ) == + 0 + ? Duration.zero + : Duration( + days: int.parse( + metadata.data!['validDuration']!, + ), + ), + /*maxStoreLength: + int.parse(metadata.data!['maxLength']!),*/ + ), + ) + : NetworkTileProvider(), + ), + if (widget.mode == MapViewMode.regionSelect) ...[ + const RegionShape(), + const CustomPolygonSnappingIndicator(), + ], + if (widget.bottomPaddingWrapperBuilder != null) + Builder( + builder: (context) => widget.bottomPaddingWrapperBuilder!( + context, + _attributionLayer, + ), + ) + else + _attributionLayer, + ], + ); + + return LayoutBuilder( + builder: (context, constraints) { + final double sidePanelLeft = + switch ((widget.layoutDirection, widget.mode)) { + (Axis.vertical, _) => 0, + (Axis.horizontal, MapViewMode.standard) => -70, + (Axis.horizontal, MapViewMode.regionSelect) => 12, + }; + final double sidePanelBottom = + switch ((widget.layoutDirection, widget.mode)) { + (Axis.horizontal, _) => 0, + (Axis.vertical, MapViewMode.standard) => -70, + (Axis.vertical, MapViewMode.regionSelect) => 12, + }; + + return Stack( + fit: StackFit.expand, + children: [ + MouseRegion( + opaque: false, + cursor: widget.mode == MapViewMode.standard || + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter + ? MouseCursor.defer + : context.select( + (p) => p.customPolygonSnap, + ) + ? SystemMouseCursors.none + : SystemMouseCursors.precise, + child: map, + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + left: sidePanelLeft, + bottom: sidePanelBottom, + + // top: widget.layoutDirection == Axis.horizontal ? 12 : null, + // bottom: 12, + //start: widget.layoutDirection == Axis.horizontal ? 24 : 12, + //end: widget.layoutDirection == Axis.horizontal ? null : 12, + child: SizedBox( + height: widget.layoutDirection == Axis.horizontal + ? constraints.maxHeight + : null, + width: widget.layoutDirection == Axis.horizontal + ? null + : constraints.maxWidth, + child: RegionSelectionSidePanel( + layoutDirection: widget.layoutDirection, + bottomPaddingWrapperBuilder: + widget.bottomPaddingWrapperBuilder, + ), + ), + ), + if (widget.mode == MapViewMode.regionSelect && + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter && + !context.select( + (p) => p.customPolygonSnap, + )) + const Center(child: Crosshairs()), + /*UsageInstructions( + layoutDirection: widget.layoutDirection, + ),*/ + ], + ); + }, + ); + }, + ), + ); + } +} diff --git a/example/lib/screens/main/pages/region_selection/components/crosshairs.dart b/example/lib/src/screens/home/map_view/region_selection_components/crosshairs.dart similarity index 100% rename from example/lib/screens/main/pages/region_selection/components/crosshairs.dart rename to example/lib/src/screens/home/map_view/region_selection_components/crosshairs.dart diff --git a/example/lib/screens/main/pages/region_selection/components/custom_polygon_snapping_indicator.dart b/example/lib/src/screens/home/map_view/region_selection_components/custom_polygon_snapping_indicator.dart similarity index 100% rename from example/lib/screens/main/pages/region_selection/components/custom_polygon_snapping_indicator.dart rename to example/lib/src/screens/home/map_view/region_selection_components/custom_polygon_snapping_indicator.dart diff --git a/example/lib/screens/main/pages/region_selection/components/region_shape.dart b/example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart similarity index 98% rename from example/lib/screens/main/pages/region_selection/components/region_shape.dart rename to example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart index 770f8c4d..e72c9ae2 100644 --- a/example/lib/screens/main/pages/region_selection/components/region_shape.dart +++ b/example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart @@ -4,7 +4,6 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import '../../../../../shared/misc/region_type.dart'; import '../state/region_selection_provider.dart'; class RegionShape extends StatelessWidget { diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/additional_pane.dart b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/additional_pane.dart similarity index 100% rename from example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/additional_pane.dart rename to example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/additional_pane.dart diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart similarity index 100% rename from example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart rename to example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/line_region_pane.dart b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/line_region_pane.dart similarity index 100% rename from example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/line_region_pane.dart rename to example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/line_region_pane.dart diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/slider_panel_base.dart b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/slider_panel_base.dart similarity index 100% rename from example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/slider_panel_base.dart rename to example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/slider_panel_base.dart diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/custom_slider_track_shape.dart b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/custom_slider_track_shape.dart similarity index 100% rename from example/lib/screens/main/pages/region_selection/components/side_panel/custom_slider_track_shape.dart rename to example/lib/src/screens/home/map_view/region_selection_components/side_panel/custom_slider_track_shape.dart diff --git a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/parent.dart b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/parent.dart new file mode 100644 index 00000000..67ce3314 --- /dev/null +++ b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/parent.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gpx/gpx.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../shared/misc/exts/interleave.dart'; +import '../../state/region_selection_provider.dart'; + +part 'additional_panes/additional_pane.dart'; +part 'additional_panes/adjust_zoom_lvls_pane.dart'; +part 'additional_panes/line_region_pane.dart'; +part 'additional_panes/slider_panel_base.dart'; +part 'custom_slider_track_shape.dart'; +part 'primary_pane.dart'; +part 'region_shape_button.dart'; + +class RegionSelectionSidePanel extends StatelessWidget { + const RegionSelectionSidePanel({ + super.key, + required this.bottomPaddingWrapperBuilder, + required Axis layoutDirection, + }) : layoutDirection = + layoutDirection == Axis.vertical ? Axis.horizontal : Axis.vertical; + + final Widget Function(BuildContext context, Widget child)? + bottomPaddingWrapperBuilder; + final Axis layoutDirection; + + void finalizeSelection(BuildContext context) { + throw UnimplementedError(); + } + + @override + Widget build(BuildContext context) { + final Widget child; + + if (layoutDirection == Axis.vertical) { + child = LayoutBuilder( + builder: (context, constraints) => IntrinsicHeight( + child: _PrimaryPane( + constraints: constraints, + layoutDirection: layoutDirection, + finalizeSelection: finalizeSelection, + ), + ), + ); + } else { + final subChild = LayoutBuilder( + builder: (context, constraints) => IntrinsicWidth( + child: _PrimaryPane( + constraints: constraints, + layoutDirection: layoutDirection, + finalizeSelection: finalizeSelection, + ), + ), + ); + + if (bottomPaddingWrapperBuilder != null) { + child = Builder( + builder: (context) => bottomPaddingWrapperBuilder!(context, subChild), + ); + } else { + child = subChild; + } + } + + return Center(child: FittedBox(child: child)); + } +} diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/primary_pane.dart b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/primary_pane.dart similarity index 97% rename from example/lib/screens/main/pages/region_selection/components/side_panel/primary_pane.dart rename to example/lib/src/screens/home/map_view/region_selection_components/side_panel/primary_pane.dart index 1c0fd3e5..8cd7d0c2 100644 --- a/example/lib/screens/main/pages/region_selection/components/side_panel/primary_pane.dart +++ b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/primary_pane.dart @@ -4,13 +4,12 @@ class _PrimaryPane extends StatelessWidget { const _PrimaryPane({ required this.constraints, required this.layoutDirection, - required this.pushToConfigureDownload, + required this.finalizeSelection, }); final BoxConstraints constraints; - final void Function() pushToConfigureDownload; - final Axis layoutDirection; + final void Function(BuildContext context) finalizeSelection; static const regionShapes = { RegionType.square: ( @@ -164,7 +163,7 @@ class _PrimaryPane extends StatelessWidget { IconButton.filled( icon: const Icon(Icons.done), onPressed: provider.region != null - ? pushToConfigureDownload + ? () => finalizeSelection(context) : null, ), ], diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/region_shape_button.dart b/example/lib/src/screens/home/map_view/region_selection_components/side_panel/region_shape_button.dart similarity index 100% rename from example/lib/screens/main/pages/region_selection/components/side_panel/region_shape_button.dart rename to example/lib/src/screens/home/map_view/region_selection_components/side_panel/region_shape_button.dart diff --git a/example/lib/screens/main/pages/region_selection/components/usage_instructions.dart b/example/lib/src/screens/home/map_view/region_selection_components/usage_instructions.dart similarity index 90% rename from example/lib/screens/main/pages/region_selection/components/usage_instructions.dart rename to example/lib/src/screens/home/map_view/region_selection_components/usage_instructions.dart index e3e90476..047ac6a2 100644 --- a/example/lib/screens/main/pages/region_selection/components/usage_instructions.dart +++ b/example/lib/src/screens/home/map_view/region_selection_components/usage_instructions.dart @@ -3,18 +3,14 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; -import '../../../../../shared/misc/region_selection_method.dart'; -import '../../../../../shared/misc/region_type.dart'; import '../state/region_selection_provider.dart'; class UsageInstructions extends StatelessWidget { - UsageInstructions({ + const UsageInstructions({ super.key, - required this.constraints, - }) : layoutDirection = - constraints.maxWidth > 1325 ? Axis.vertical : Axis.horizontal; + required this.layoutDirection, + }); - final BoxConstraints constraints; final Axis layoutDirection; @override @@ -85,7 +81,8 @@ class UsageInstructions extends StatelessWidget { ), const SizedBox.square(dimension: 2), AutoSizeText( - 'Tap/click to add ${provider.regionType == RegionType.circle ? 'center' : 'point'}', + 'Tap/click to add ' + '${provider.regionType == RegionType.circle ? 'center' : 'point'}', maxLines: 1, ), AutoSizeText( diff --git a/example/lib/screens/main/pages/map/state/map_provider.dart b/example/lib/src/screens/home/map_view/state/map_provider.dart similarity index 94% rename from example/lib/screens/main/pages/map/state/map_provider.dart rename to example/lib/src/screens/home/map_view/state/map_provider.dart index 0228b98e..be4445f8 100644 --- a/example/lib/screens/main/pages/map/state/map_provider.dart +++ b/example/lib/src/screens/home/map_view/state/map_provider.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +/*import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -25,4 +25,4 @@ class MapProvider extends ChangeNotifier { _animateTo = newMethod; notifyListeners(); } -} +}*/ diff --git a/example/lib/screens/main/pages/region_selection/state/region_selection_provider.dart b/example/lib/src/screens/home/map_view/state/region_selection_provider.dart similarity index 96% rename from example/lib/screens/main/pages/region_selection/state/region_selection_provider.dart rename to example/lib/src/screens/home/map_view/state/region_selection_provider.dart index fac3ec8d..8eb62d2e 100644 --- a/example/lib/screens/main/pages/region_selection/state/region_selection_provider.dart +++ b/example/lib/src/screens/home/map_view/state/region_selection_provider.dart @@ -4,8 +4,17 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:latlong2/latlong.dart'; -import '../../../../../shared/misc/region_selection_method.dart'; -import '../../../../../shared/misc/region_type.dart'; +enum RegionSelectionMethod { + useMapCenter, + usePointer, +} + +enum RegionType { + square, + circle, + line, + customPolygon, +} class RegionSelectionProvider extends ChangeNotifier { RegionSelectionMethod _regionSelectionMethod = diff --git a/example/lib/screens/initialisation_error/initialisation_error.dart b/example/lib/src/screens/initialisation_error/initialisation_error.dart similarity index 83% rename from example/lib/screens/initialisation_error/initialisation_error.dart rename to example/lib/src/screens/initialisation_error/initialisation_error.dart index 7cae2a7b..a732e5eb 100644 --- a/example/lib/screens/initialisation_error/initialisation_error.dart +++ b/example/lib/src/screens/initialisation_error/initialisation_error.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; -import '../../main.dart'; +import '../../../main.dart'; class InitialisationError extends StatelessWidget { const InitialisationError({super.key, required this.err}); @@ -13,8 +13,8 @@ class InitialisationError extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - body: SingleChildScrollView( - padding: const EdgeInsets.all(32), + body: Padding( + padding: const EdgeInsets.all(16), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -23,13 +23,10 @@ class InitialisationError extends StatelessWidget { const SizedBox(height: 12), Text( 'Whoops, look like FMTC ran into an error initialising', - style: Theme.of(context) - .textTheme - .displaySmall! - .copyWith(color: Colors.white), + style: Theme.of(context).textTheme.displaySmall, textAlign: TextAlign.center, ), - const SizedBox(height: 32), + const SizedBox(height: 16), SelectableText( 'Type: ${err.runtimeType}', style: Theme.of(context).textTheme.headlineSmall, @@ -40,19 +37,16 @@ class InitialisationError extends StatelessWidget { style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ), - const SizedBox(height: 32), + const SizedBox(height: 16), Text( 'We recommend trying to delete the existing root, as it may ' 'have become corrupt.\nPlease be aware that this will delete ' 'any cached data, and will cause the app to restart.', - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith(color: Colors.white), + style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ), - const SizedBox(height: 16), - OutlinedButton( + const SizedBox(height: 32), + FilledButton( onPressed: () async { void showFailure() { if (context.mounted) { @@ -88,9 +82,8 @@ class InitialisationError extends StatelessWidget { rethrow; } - runApp(const SizedBox.shrink()); - - main(); + runApp(const SizedBox.shrink()); // Destroy current app + main(); // Re-run app }, child: const Text( 'Reset FMTC & attempt re-initialisation', diff --git a/example/lib/shared/components/build_attribution.dart b/example/lib/src/shared/components/build_attribution.dart similarity index 100% rename from example/lib/shared/components/build_attribution.dart rename to example/lib/src/shared/components/build_attribution.dart diff --git a/example/lib/src/shared/components/delayed_frame_attached_dependent_builder.dart b/example/lib/src/shared/components/delayed_frame_attached_dependent_builder.dart new file mode 100644 index 00000000..1d2e61b5 --- /dev/null +++ b/example/lib/src/shared/components/delayed_frame_attached_dependent_builder.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +/// Builds [builder] whenever [listenable] fires a notififcation, but also +/// rebuilds [builder] after at least one frame, to allow [listenable] (which is +/// usually some type of controller) to attach itself to a widget elsewhere in +/// the tree +/// +/// [builder] must not assume [listenable] is attached. The purpose of this +/// widget is not to remove the requirement for an initial value (which is +/// extremely difficult/impossible), but to eliminate the unnnecessary frame lag +/// after attachment. +class DelayedControllerAttachmentBuilder extends StatefulWidget { + const DelayedControllerAttachmentBuilder({ + super.key, + required this.listenable, + required this.builder, + this.child, + }); + + final Listenable listenable; + final Widget Function(BuildContext context, Widget? child) builder; + final Widget? child; + + @override + State createState() => + _DelayedControllerAttachmentBuilderState(); +} + +class _DelayedControllerAttachmentBuilderState + extends State { + // When used in combination with `FutureBuilder`, which can build at most + // once per frame, this means the future completes in the next microtask, + // which is at least the next frame. + // + // The listenable (which is a controller) should attach itself to whatever is + // required by this point, as that should take at most one frame. + final delayFrameFuture = Future.microtask(() => null); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: delayFrameFuture, + builder: (context, _) => AnimatedBuilder( + animation: widget.listenable, + builder: widget.builder, + child: widget.child, + ), + ); +} diff --git a/example/lib/shared/components/loading_indicator.dart b/example/lib/src/shared/components/loading_indicator.dart similarity index 100% rename from example/lib/shared/components/loading_indicator.dart rename to example/lib/src/shared/components/loading_indicator.dart diff --git a/example/lib/src/shared/misc/circular_buffer.dart b/example/lib/src/shared/misc/circular_buffer.dart new file mode 100644 index 00000000..93cda718 --- /dev/null +++ b/example/lib/src/shared/misc/circular_buffer.dart @@ -0,0 +1,59 @@ +import 'dart:collection'; + +/// A list with a fixed length ([capacity]) that continuously overwrites the +/// oldest element as necessary +final class CircularBuffer with ListMixin { + CircularBuffer({required this.capacity}); + + /// Maximum number of elements + final int capacity; + + final _buffer = []; + int _ptr = 0; + + /// Whether the queue capacity has been entirely consumed + bool get isFilled => _buffer.length == capacity; + + @override + void add(T element) { + if (!isFilled) return _buffer.add(element); + _buffer[_ptr] = element; + _ptr++; + if (_ptr == capacity) _ptr = 0; + } + + int _calcActualIndex(int i) => (_ptr + i) % _buffer.length; + + @override + T operator [](int index) { + if (index < 0 || index >= _buffer.length) { + throw RangeError.index(index, this); + } + return _buffer[_calcActualIndex(index)]; + } + + @override + void operator []=(int index, T value) { + if (index < 0 || index >= _buffer.length) { + throw RangeError.index(index, this); + } + _buffer[_calcActualIndex(index)] = value; + } + + /// Number of consumed elements of queue + @override + int get length => _buffer.length; + + /// It is forbidden to modify the length of a `CircularQueue` + @override + set length(int newLength) { + throw UnsupportedError('Unable to resize a `CircularQueue`'); + } + + /// Empties the queue + @override + void clear() { + _ptr = 0; + _buffer.clear(); + } +} diff --git a/example/lib/shared/misc/exts/interleave.dart b/example/lib/src/shared/misc/exts/interleave.dart similarity index 100% rename from example/lib/shared/misc/exts/interleave.dart rename to example/lib/src/shared/misc/exts/interleave.dart diff --git a/example/lib/shared/misc/exts/size_formatter.dart b/example/lib/src/shared/misc/exts/size_formatter.dart similarity index 100% rename from example/lib/shared/misc/exts/size_formatter.dart rename to example/lib/src/shared/misc/exts/size_formatter.dart diff --git a/example/lib/shared/misc/region_selection_method.dart b/example/lib/src/shared/misc/region_selection_method.dart similarity index 100% rename from example/lib/shared/misc/region_selection_method.dart rename to example/lib/src/shared/misc/region_selection_method.dart diff --git a/example/lib/shared/misc/region_type.dart b/example/lib/src/shared/misc/region_type.dart similarity index 100% rename from example/lib/shared/misc/region_type.dart rename to example/lib/src/shared/misc/region_type.dart diff --git a/example/lib/src/shared/misc/shared_preferences.dart b/example/lib/src/shared/misc/shared_preferences.dart new file mode 100644 index 00000000..904d46ef --- /dev/null +++ b/example/lib/src/shared/misc/shared_preferences.dart @@ -0,0 +1,9 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +late SharedPreferences sharedPrefs; + +enum SharedPrefsKeys { + mapLocationLat, + mapLocationLng, + mapLocationZoom, +} diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart new file mode 100644 index 00000000..fc87bb21 --- /dev/null +++ b/example/lib/src/shared/state/general_provider.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +class GeneralProvider extends ChangeNotifier { + Set _currentStores = {}; + Set get currentStores => _currentStores; + set currentStores(Set newStores) { + _currentStores = newStores; + notifyListeners(); + } + + void removeStore(String store) { + _currentStores.remove(store); + notifyListeners(); + } + + void addStore(String store) { + _currentStores.add(store); + notifyListeners(); + } + + bool? _storesSelectionMode = true; + bool? get storesSelectionMode => _storesSelectionMode; + set storesSelectionMode(bool? newSelectionMode) { + _storesSelectionMode = newSelectionMode; + notifyListeners(); + } + + final StreamController resetController = StreamController.broadcast(); + void resetMap() => resetController.add(null); +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 860c6ad4..fe974ecd 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: file_picker: ^8.0.3 flutter: sdk: flutter - flutter_map: ^7.0.0 + flutter_map: ^7.0.1 flutter_map_animations: ^0.7.0 flutter_map_tile_caching: google_fonts: ^6.2.1 @@ -30,6 +30,7 @@ dependencies: path: ^1.9.0 path_provider: ^2.1.3 provider: ^6.1.2 + shared_preferences: ^2.2.3 stream_transform: ^2.1.0 validators: ^3.0.0 version: ^3.0.2 diff --git a/lib/src/root/recovery.dart b/lib/src/root/recovery.dart index 592e3665..a3eb249c 100644 --- a/lib/src/root/recovery.dart +++ b/lib/src/root/recovery.dart @@ -35,14 +35,14 @@ part of '../../flutter_map_tile_caching.dart'; /// > Options set at download time, in [StoreDownload.startForeground], are not /// > included. class RootRecovery { - RootRecovery._() { - _instance = this; - } + factory RootRecovery._() => _instance ??= const RootRecovery._uninstanced({}); + const RootRecovery._uninstanced(Set downloadsOngoing) + : _downloadsOngoing = downloadsOngoing; static RootRecovery? _instance; /// Determines which downloads are known to be on-going, and therefore /// can be ignored when fetching [recoverableRegions] - final Set _downloadsOngoing = {}; + final Set _downloadsOngoing; /// List all recoverable regions, and whether each one has failed /// diff --git a/lib/src/root/root.dart b/lib/src/root/root.dart index d214344d..f5fa84d0 100644 --- a/lib/src/root/root.dart +++ b/lib/src/root/root.dart @@ -17,8 +17,7 @@ abstract class FMTCRoot { static RootStats get stats => const RootStats._(); /// Manage the download recovery of all sub-stores - static RootRecovery get recovery => - RootRecovery._instance ?? RootRecovery._(); + static RootRecovery get recovery => RootRecovery._(); /// Export & import 'archives' of selected stores and tiles, outside of the /// FMTC environment From 8f5ce14c6f10e8260a4ce730f38eb1d8d006233d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 12 Jun 2024 21:49:28 +0100 Subject: [PATCH 06/97] Improved performance of recovery updates during bulk downloading (only update when buffer cleared) Improved performance of bulk download thread buffer trackers --- lib/src/bulk_download/manager.dart | 105 ++++++++++-------- .../bulk_download/tile_loops/generate.dart | 2 +- 2 files changed, 57 insertions(+), 50 deletions(-) diff --git a/lib/src/bulk_download/manager.dart b/lib/src/bulk_download/manager.dart index 6aa7047e..2de7ddbd 100644 --- a/lib/src/bulk_download/manager.dart +++ b/lib/src/bulk_download/manager.dart @@ -63,13 +63,12 @@ Future _downloadManager( } // Setup thread buffer tracking - late final List<({double size, int tiles})> threadBuffers; + late final List threadBuffersSize; + late final List threadBuffersTiles; if (input.maxBufferLength != 0) { - threadBuffers = List.generate( - input.parallelThreads, - (_) => (tiles: 0, size: 0.0), - growable: false, - ); + // TODO: Verify `filled` + threadBuffersSize = List.filled(input.parallelThreads, 0); + threadBuffersTiles = List.filled(input.parallelThreads, 0); } // Setup tile generator isolate @@ -124,32 +123,32 @@ Future _downloadManager( void send(Object? m) => input.sendPort.send(m); // Setup cancel, pause, and resume handling - List> generateThreadPausedStates() => List.generate( + Iterable> generateThreadPausedStates() => Iterable.generate( input.parallelThreads, (_) => Completer(), - growable: false, ); - final threadPausedStates = generateThreadPausedStates(); + final threadPausedStates = generateThreadPausedStates().toList(); final cancelSignal = Completer(); var pauseResumeSignal = Completer()..complete(); rootReceivePort.listen( (cmd) async { - if (cmd == _DownloadManagerControlCmd.cancel) { - try { - cancelSignal.complete(); - // ignore: avoid_catching_errors, empty_catches - } on StateError {} - } else if (cmd == _DownloadManagerControlCmd.pause) { - pauseResumeSignal = Completer(); - threadPausedStates.setAll(0, generateThreadPausedStates()); - await Future.wait(threadPausedStates.map((e) => e.future)); - downloadDuration.stop(); - send(_DownloadManagerControlCmd.pause); - } else if (cmd == _DownloadManagerControlCmd.resume) { - pauseResumeSignal.complete(); - downloadDuration.start(); - } else { - throw UnimplementedError('Recieved unknown cmd: $cmd'); + switch (cmd) { + case _DownloadManagerControlCmd.cancel: + try { + cancelSignal.complete(); + // ignore: avoid_catching_errors, empty_catches + } on StateError {} + case _DownloadManagerControlCmd.pause: + pauseResumeSignal = Completer(); + threadPausedStates.setAll(0, generateThreadPausedStates()); + await Future.wait(threadPausedStates.map((e) => e.future)); + downloadDuration.stop(); + send(_DownloadManagerControlCmd.pause); + case _DownloadManagerControlCmd.resume: + pauseResumeSignal.complete(); + downloadDuration.start(); + default: + throw UnimplementedError('Recieved unknown control cmd: $cmd'); } }, ); @@ -187,6 +186,18 @@ Future _downloadManager( // send(2); } + // Create convienience method to update recovery system if enabled + void updateRecoveryIfNecessary() { + if (input.recoveryId case final recoveryId?) { + input.backend.updateRecovery( + id: recoveryId, + newStartTile: 1 + + (lastDownloadProgress.cachedTiles - + lastDownloadProgress.bufferedTiles), + ); + } + } + // Duplicate the backend to make it safe to send through isolates final threadBackend = input.backend.duplicate(); @@ -239,33 +250,37 @@ Future _downloadManager( if (evt is TileEvent) { // If buffering is in use, send a progress update with buffer info if (input.maxBufferLength != 0) { + // Update correct thread buffer with new tile on success if (evt.result == TileEventResult.success) { - threadBuffers[threadNo] = ( - tiles: evt._wasBufferReset - ? 0 - : threadBuffers[threadNo].tiles + 1, - size: evt._wasBufferReset - ? 0 - : threadBuffers[threadNo].size + - (evt.tileImage!.lengthInBytes / 1024) - ); + if (evt._wasBufferReset) { + threadBuffersSize[threadNo] = 0; + threadBuffersTiles[threadNo] = 0; + } else { + threadBuffersSize[threadNo] += evt.tileImage!.lengthInBytes; + threadBuffersTiles[threadNo]++; + } } send( lastDownloadProgress = lastDownloadProgress._updateProgressWithTile( newTileEvent: evt, - newBufferedTiles: threadBuffers - .map((e) => e.tiles) - .reduce((a, b) => a + b), - newBufferedSize: threadBuffers - .map((e) => e.size) - .reduce((a, b) => a + b), + newBufferedTiles: + threadBuffersTiles.reduce((a, b) => a + b), + newBufferedSize: + threadBuffersSize.reduce((a, b) => a + b) / 1024, newDuration: downloadDuration.elapsed, tilesPerSecond: getCurrentTPS(registerNewTPS: true), rateLimit: input.rateLimit, ), ); + + // For efficiency, only update recovery when the buffer is + // cleaned + // We don't want to update recovery to a tile that isn't cached + // (only buffered), because they'll be lost in the events + // recovery is designed to recover from + if (evt._wasBufferReset) updateRecoveryIfNecessary(); } else { send( lastDownloadProgress = @@ -278,16 +293,8 @@ Future _downloadManager( rateLimit: input.rateLimit, ), ); - } - // TODO: Make updates batched to improve efficiency - if (input.recoveryId case final recoveryId?) { - input.backend.updateRecovery( - id: recoveryId, - newStartTile: 1 + - (lastDownloadProgress.cachedTiles - - lastDownloadProgress.bufferedTiles), - ); + updateRecoveryIfNecessary(); } return; diff --git a/lib/src/bulk_download/tile_loops/generate.dart b/lib/src/bulk_download/tile_loops/generate.dart index 430491ca..acdc4df1 100644 --- a/lib/src/bulk_download/tile_loops/generate.dart +++ b/lib/src/bulk_download/tile_loops/generate.dart @@ -81,7 +81,7 @@ class TileGenerators { // 1. Calculate the radius in tiles using `Distance` // 2. Iterate through y, then x // 3. Use the circle formula x^2 + y^2 = r^2 to determine all points within the radius - // However, effectively scaling this proved to be difficult. + // However, effectively scaling this to 256x256 tiles proved to be difficult. final region = input.region as DownloadableRegion; final circleOutline = region.originalRegion.toOutline(); From 37fc1bf0227c767d6d9a209e4192c998627681fc Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 18 Jun 2024 22:46:18 +0100 Subject: [PATCH 07/97] More example app improvements --- example/lib/main.dart | 286 +--------------- .../components/numerical_input_row.dart | 136 ++++++++ .../components/options_pane.dart | 44 +++ .../components/region_information.dart | 249 ++++++++++++++ .../components/start_download_button.dart | 134 ++++++++ .../components/store_selector.dart | 55 ++++ .../configure_download.dart | 160 +++++++++ .../state/configure_download_provider.dart | 51 +++ .../config_panel/components/stores_list.dart | 211 ------------ .../screens/home/config_panel/map_config.dart | 137 -------- .../wrappers/side_panel/side_panel.dart | 66 ---- .../forms}/bottom_sheet/bottom_sheet.dart | 38 ++- .../bottom_sheet/components/contents.dart} | 40 +-- .../components/scrollable_provider.dart | 0 .../bottom_sheet}/components/tab_header.dart | 8 +- .../bottom_sheet/components/toolbar.dart | 4 +- .../home/config_view/forms/side/side.dart | 110 +++++++ .../config_view/forms/side_panel/side.dart | 106 ++++++ .../panels/behaviour/behaviour.dart | 76 +++++ .../home/config_view/panels/map/map.dart | 48 +++ .../panels/stores/components/list.dart | 76 +++++ .../stores/components/new_store_button.dart | 35 ++ .../panels/stores/components/no_stores.dart | 55 ++++ .../panels/stores/components/store_tile.dart | 149 +++++++++ .../config_view/panels/stores/stores.dart | 54 +++ example/lib/src/screens/home/home.dart | 57 ++-- .../src/screens/home/map_view/map_view.dart | 255 +++++++------- .../custom_polygon_snapping_indicator.dart | 14 +- .../region_shape.dart | 14 +- .../usage_instructions.dart | 106 ------ .../screens/store_editor/store_editor.dart | 310 ++++++++++++++++++ .../src/shared/components/url_selector.dart | 254 ++++++++++++++ .../src/shared/misc/store_metadata_keys.dart | 6 + .../src/shared/state/general_provider.dart | 38 +++ example/pubspec.yaml | 3 + lib/src/providers/tile_provider_settings.dart | 5 + lib/src/regions/circle.dart | 2 + 37 files changed, 2386 insertions(+), 1006 deletions(-) create mode 100644 example/lib/src/screens/configure_download/components/numerical_input_row.dart create mode 100644 example/lib/src/screens/configure_download/components/options_pane.dart create mode 100644 example/lib/src/screens/configure_download/components/region_information.dart create mode 100644 example/lib/src/screens/configure_download/components/start_download_button.dart create mode 100644 example/lib/src/screens/configure_download/components/store_selector.dart create mode 100644 example/lib/src/screens/configure_download/configure_download.dart create mode 100644 example/lib/src/screens/configure_download/state/configure_download_provider.dart delete mode 100644 example/lib/src/screens/home/config_panel/components/stores_list.dart delete mode 100644 example/lib/src/screens/home/config_panel/map_config.dart delete mode 100644 example/lib/src/screens/home/config_panel/wrappers/side_panel/side_panel.dart rename example/lib/src/screens/home/{config_panel/wrappers => config_view/forms}/bottom_sheet/bottom_sheet.dart (80%) rename example/lib/src/screens/home/{config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart => config_view/forms/bottom_sheet/components/contents.dart} (60%) rename example/lib/src/screens/home/{config_panel/wrappers => config_view/forms}/bottom_sheet/components/scrollable_provider.dart (100%) rename example/lib/src/screens/home/{config_panel/wrappers/bottom_sheet/tabs/stores => config_view/forms/bottom_sheet}/components/tab_header.dart (94%) rename example/lib/src/screens/home/{config_panel/wrappers => config_view/forms}/bottom_sheet/components/toolbar.dart (93%) create mode 100644 example/lib/src/screens/home/config_view/forms/side/side.dart create mode 100644 example/lib/src/screens/home/config_view/forms/side_panel/side.dart create mode 100644 example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart create mode 100644 example/lib/src/screens/home/config_view/panels/map/map.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/list.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/stores.dart delete mode 100644 example/lib/src/screens/home/map_view/region_selection_components/usage_instructions.dart create mode 100644 example/lib/src/screens/store_editor/store_editor.dart create mode 100644 example/lib/src/shared/components/url_selector.dart create mode 100644 example/lib/src/shared/misc/store_metadata_keys.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 8cf56ed7..3f0d8e9d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,261 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'src/screens/home/home.dart'; import 'src/screens/home/map_view/state/region_selection_provider.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; +import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; import 'src/shared/state/general_provider.dart'; -/*void main() { - runApp(RootWidget()); -} - -class RootWidget extends StatelessWidget { - const RootWidget({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: HomeWidget(), - ); - } -} - -class HomeWidget extends StatefulWidget { - const HomeWidget({super.key}); - - @override - State createState() => _HomeWidgetState(); -} - -class _HomeWidgetState extends State { - final bottomSheetOuterController = DraggableScrollableController(); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SizedBox.expand( - child: ColoredBox( - color: Colors.red, - child: Center( - child: ElevatedButton( - onPressed: () { - print(bottomSheetOuterController.isAttached); - bottomSheetOuterController.animateTo( - 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeIn, - ); - }, - child: Text('expand'), - ), - ), - ), - ), - bottomSheet: DraggableScrollableSheet( - initialChildSize: 0.3, - expand: false, - controller: bottomSheetOuterController, - builder: (context, innerController) => ColoredBox( - color: Colors.blue, - child: SizedBox.expand( - child: Center( - child: ElevatedButton( - onPressed: () { - print(bottomSheetOuterController.isAttached); - bottomSheetOuterController.animateTo( - 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeIn, - ); - }, - child: Text('expand'), - ), - ), - ), - ), - ), - /*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', - ), - ], - onDestinationSelected: (i) { - if (i == 1) { - bottomSheetOuterController.animateTo( - 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeIn, - ); - } else { - bottomSheetOuterController.animateTo( - 0.3, - duration: const Duration(milliseconds: 200), - curve: Curves.easeIn, - ); - } - }, - ),*/ - ); - } -} - -class CustomBottomSheet extends StatefulWidget { - const CustomBottomSheet({ - super.key, - required this.controller, - }); - - final DraggableScrollableController controller; - - @override - State createState() => _CustomBottomSheetState(); -} - -class _CustomBottomSheetState extends State { - @override - Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.3, - minChildSize: 0, - snap: true, - expand: false, - snapSizes: const [0.3], - controller: widget.controller, - builder: (context, innerController) => ColoredBox( - color: Colors.blue, - child: SizedBox.expand( - child: Center( - child: ElevatedButton( - onPressed: () { - print(widget.controller.isAttached); - widget.controller.animateTo( - 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeIn, - ); - }, - child: Text('expand'), - ), - ), - ), - ), - /* DelayedControllerAttachmentBuilder( - listenable: widget.controller, - builder: (context, child) { - double radius = 18; - double calcHeight = 0; - - if (widget.controller.isAttached) { - final maxHeight = widget.controller.sizeToPixels(1); - - final oldValue = widget.controller.pixels; - final oldMax = maxHeight; - final oldMin = maxHeight - radius; - const newMax = 0.0; - final newMin = radius; - - radius = ((((oldValue - oldMin) * (newMax - newMin)) / - (oldMax - oldMin)) + - newMin) - .clamp(0, radius); - - calcHeight = screenTopPadding - - constraints.maxHeight + - widget.controller.pixels; - } - - return ClipRRect( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(radius), - topRight: Radius.circular(radius), - ), - child: Column( - children: [ - DelayedControllerAttachmentBuilder( - listenable: innerController, - builder: (context, _) => SizedBox( - height: calcHeight.clamp(0, screenTopPadding), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - color: innerController.hasClients && - innerController.offset != 0 - ? Theme.of(context) - .colorScheme - .surfaceContainerLowest - : Theme.of(context).colorScheme.surfaceContainerLow, - ), - ), - ), - Expanded( - child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: child, - ), - ), - ], - ), - ); - }, - child: Stack( - children: [ - BottomSheetScrollableProvider( - innerScrollController: innerController, - child: widget.child, - ), - IgnorePointer( - child: DelayedControllerAttachmentBuilder( - listenable: widget.controller, - builder: (context, _) { - if (!widget.controller.isAttached) { - return const SizedBox.shrink(); - } - - final calcHeight = BottomSheet.topPadding - - (screenTopPadding - - constraints.maxHeight + - widget.controller.pixels); - - return SizedBox( - height: calcHeight.clamp(0, BottomSheet.topPadding), - width: constraints.maxWidth, - child: Semantics( - label: MaterialLocalizations.of(context) - .modalBarrierDismissLabel, - container: true, - child: Center( - child: Container( - height: 4, - width: 32, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(2), - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withOpacity(0.4), - ), - ), - ), - ), - ); - }, - ), - ), - ], - ), - ),*/ - ); - } -}*/ - void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -288,20 +37,21 @@ class _AppContainer extends StatelessWidget { static final _routes = Function({ - required Widget Function( - BuildContext, - Animation, - Animation, - ) pageBuilder, - required RouteSettings settings, - })? custom, - Widget Function(BuildContext) std, + Widget Function(BuildContext)? std, + PageRoute Function(BuildContext, RouteSettings)? custom, })>{ HomeScreen.route: ( std: (BuildContext context) => const HomeScreen(), custom: null, ), + StoreEditorPopup.route: ( + std: null, + custom: (context, settings) => MaterialPageRoute( + builder: (context) => const StoreEditorPopup(), + settings: settings, + fullscreenDialog: true, + ), + ), /*ManageOfflineScreen.route: ( std: (BuildContext context) => ManageOfflineScreen(), custom: null, @@ -346,7 +96,7 @@ class _AppContainer extends StatelessWidget { brightness: Brightness.light, useMaterial3: true, textTheme: GoogleFonts.ubuntuTextTheme(ThemeData.light().textTheme), - colorSchemeSeed: Colors.orange, + colorSchemeSeed: Colors.teal, switchTheme: SwitchThemeData( thumbIcon: WidgetStateProperty.resolveWith( (states) => states.contains(WidgetState.selected) @@ -393,16 +143,8 @@ class _AppContainer extends StatelessWidget { initialRoute: HomeScreen.route, onGenerateRoute: (settings) { final route = _routes[settings.name]!; - if (route.custom != null) { - return route.custom!( - pageBuilder: (context, _, __) => route.std(context), - settings: settings, - ); - } - return MaterialPageRoute( - builder: route.std, - settings: settings, - ); + if (route.custom != null) return route.custom!(context, settings); + return MaterialPageRoute(builder: route.std!, settings: settings); }, ), ); diff --git a/example/lib/src/screens/configure_download/components/numerical_input_row.dart b/example/lib/src/screens/configure_download/components/numerical_input_row.dart new file mode 100644 index 00000000..a3fbf60d --- /dev/null +++ b/example/lib/src/screens/configure_download/components/numerical_input_row.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../state/configure_download_provider.dart'; + +class NumericalInputRow extends StatefulWidget { + const NumericalInputRow({ + super.key, + required this.label, + required this.suffixText, + required this.value, + required this.min, + required this.max, + this.maxEligibleTilesPreview, + required this.onChanged, + }); + + final String label; + final String suffixText; + final int Function(ConfigureDownloadProvider provider) value; + final int min; + final int? max; + final int? maxEligibleTilesPreview; + final void Function(ConfigureDownloadProvider provider, int value) onChanged; + + @override + State createState() => _NumericalInputRowState(); +} + +class _NumericalInputRowState extends State { + TextEditingController? tec; + + @override + Widget build(BuildContext context) => + Selector( + selector: (context, provider) => widget.value(provider), + builder: (context, currentValue, _) { + tec ??= TextEditingController(text: currentValue.toString()); + + return Row( + children: [ + Text(widget.label), + const Spacer(), + if (widget.maxEligibleTilesPreview != null) ...[ + IconButton( + icon: const Icon(Icons.visibility), + disabledColor: Colors.green, + tooltip: currentValue > widget.maxEligibleTilesPreview! + ? 'Tap to enable following download live' + : 'Eligible to follow download live', + onPressed: currentValue > widget.maxEligibleTilesPreview! + ? () { + widget.onChanged( + context.read(), + widget.maxEligibleTilesPreview!, + ); + tec!.text = widget.maxEligibleTilesPreview.toString(); + } + : null, + ), + const SizedBox(width: 8), + ], + if (widget.max != null) ...[ + Tooltip( + message: currentValue == widget.max + ? 'Limited in the example app' + : '', + child: Icon( + Icons.lock, + color: currentValue == widget.max + ? Colors.amber + : Colors.white.withOpacity(0.2), + ), + ), + const SizedBox(width: 16), + ], + IntrinsicWidth( + child: TextFormField( + controller: tec, + textAlign: TextAlign.end, + keyboardType: TextInputType.number, + decoration: InputDecoration( + isDense: true, + counterText: '', + suffixText: ' ${widget.suffixText}', + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + _NumericalRangeFormatter( + min: widget.min, + max: widget.max ?? 9223372036854775807, + ), + ], + onChanged: (newVal) => widget.onChanged( + context.read(), + int.tryParse(newVal) ?? currentValue, + ), + ), + ), + ], + ); + }, + ); +} + +class _NumericalRangeFormatter extends TextInputFormatter { + const _NumericalRangeFormatter({required this.min, required this.max}); + final int min; + final int max; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.isEmpty) return newValue; + + final int parsed = int.parse(newValue.text); + + if (parsed < min) { + return TextEditingValue.empty.copyWith( + text: min.toString(), + selection: TextSelection.collapsed(offset: min.toString().length), + ); + } + if (parsed > max) { + return TextEditingValue.empty.copyWith( + text: max.toString(), + selection: TextSelection.collapsed(offset: max.toString().length), + ); + } + + return newValue; + } +} diff --git a/example/lib/src/screens/configure_download/components/options_pane.dart b/example/lib/src/screens/configure_download/components/options_pane.dart new file mode 100644 index 00000000..1993455b --- /dev/null +++ b/example/lib/src/screens/configure_download/components/options_pane.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../shared/misc/exts/interleave.dart'; + +class OptionsPane extends StatelessWidget { + const OptionsPane({ + super.key, + required this.label, + required this.children, + this.interPadding = 8, + }); + + final String label; + final Iterable children; + final double interPadding; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 14), + child: Text(label), + ), + const SizedBox.square(dimension: 4), + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: children.singleOrNull ?? + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children + .interleave(SizedBox.square(dimension: interPadding)) + .toList(), + ), + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/configure_download/components/region_information.dart b/example/lib/src/screens/configure_download/components/region_information.dart new file mode 100644 index 00000000..b661e49e --- /dev/null +++ b/example/lib/src/screens/configure_download/components/region_information.dart @@ -0,0 +1,249 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:dart_earcut/dart_earcut.dart'; +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:latlong2/latlong.dart'; + +class RegionInformation extends StatefulWidget { + const RegionInformation({ + super.key, + required this.region, + required this.minZoom, + required this.maxZoom, + required this.startTile, + required this.endTile, + }); + + final BaseRegion region; + final int minZoom; + final int maxZoom; + final int startTile; + final int? endTile; + + @override + State createState() => _RegionInformationState(); +} + +class _RegionInformationState extends State { + final distance = const Distance(roundResult: false).distance; + + late Future numOfTiles; + + @override + void initState() { + super.initState(); + numOfTiles = const FMTCStore('').download.check( + widget.region.toDownloadable( + minZoom: widget.minZoom, + maxZoom: widget.maxZoom, + options: TileLayer(), + ), + ); + } + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...widget.region.when( + rectangle: (rectangle) => [ + const Text('TOTAL AREA'), + Text( + '${(distance(rectangle.bounds.northWest, rectangle.bounds.northEast) * distance(rectangle.bounds.northEast, rectangle.bounds.southEast) / 1000000).toStringAsFixed(3)} km²', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('APPROX. NORTH WEST'), + Text( + '${rectangle.bounds.northWest.latitude.toStringAsFixed(3)}, ${rectangle.bounds.northWest.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('APPROX. SOUTH EAST'), + Text( + '${rectangle.bounds.southEast.latitude.toStringAsFixed(3)}, ${rectangle.bounds.southEast.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ], + circle: (circle) => [ + const Text('TOTAL AREA'), + Text( + '${(pi * pow(circle.radius, 2)).toStringAsFixed(3)} km²', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('RADIUS'), + Text( + '${circle.radius.toStringAsFixed(2)} km', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('APPROX. CENTER'), + Text( + '${circle.center.latitude.toStringAsFixed(3)}, ${circle.center.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ], + line: (line) { + double totalDistance = 0; + + for (int i = 0; i < line.line.length - 1; i++) { + totalDistance += + distance(line.line[i], line.line[i + 1]); + } + + return [ + const Text('LINE LENGTH'), + Text( + '${(totalDistance / 1000).toStringAsFixed(3)} km', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('FIRST COORD'), + Text( + '${line.line[0].latitude.toStringAsFixed(3)}, ${line.line[0].longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('LAST COORD'), + Text( + '${line.line.last.latitude.toStringAsFixed(3)}, ${line.line.last.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ]; + }, + customPolygon: (customPolygon) { + double area = 0; + + for (final triangle in Earcut.triangulateFromPoints( + customPolygon.outline + .map(const Epsg3857().projection.project), + ).map(customPolygon.outline.elementAt).slices(3)) { + final a = distance(triangle[0], triangle[1]); + final b = distance(triangle[1], triangle[2]); + final c = distance(triangle[2], triangle[0]); + + area += 0.25 * + sqrt( + 4 * a * a * b * b - pow(a * a + b * b - c * c, 2), + ); + } + + return [ + const Text('TOTAL AREA'), + Text( + '${(area / 1000000).toStringAsFixed(3)} km²', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ]; + }, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text('ZOOM LEVELS'), + Text( + '${widget.minZoom} - ${widget.maxZoom}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('TOTAL TILES'), + FutureBuilder( + future: numOfTiles, + builder: (context, snapshot) => snapshot.data == null + ? Padding( + padding: const EdgeInsets.only(top: 4), + child: SizedBox( + height: 36, + width: 36, + child: Center( + child: SizedBox( + height: 28, + width: 28, + child: CircularProgressIndicator( + color: + Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + ) + : Text( + NumberFormat('###,###').format(snapshot.data), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ), + const SizedBox(height: 10), + const Text('TILES RANGE'), + if (widget.startTile == 1 && widget.endTile == null) + const Text( + '*', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ) + else + Text( + '${NumberFormat('###,###').format(widget.startTile)} - ${widget.endTile != null ? NumberFormat('###,###').format(widget.endTile) : '*'}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ], + ), + ], + ), + ], + ); +} diff --git a/example/lib/src/screens/configure_download/components/start_download_button.dart b/example/lib/src/screens/configure_download/components/start_download_button.dart new file mode 100644 index 00000000..f426d87a --- /dev/null +++ b/example/lib/src/screens/configure_download/components/start_download_button.dart @@ -0,0 +1,134 @@ +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:provider/provider.dart'; + +import '../../home/map_view/state/region_selection_provider.dart'; +import '../state/configure_download_provider.dart'; + +class StartDownloadButton extends StatelessWidget { + const StartDownloadButton({ + super.key, + required this.region, + required this.minZoom, + required this.maxZoom, + required this.startTile, + required this.endTile, + }); + + final BaseRegion region; + final int minZoom; + final int maxZoom; + final int startTile; + final int? endTile; + + @override + Widget build(BuildContext context) => + Selector( + selector: (context, provider) => provider.isReady, + builder: (context, isReady, _) => + Selector( + selector: (context, provider) => provider.selectedStore, + builder: (context, selectedStore, child) => IgnorePointer( + ignoring: selectedStore == null, + child: AnimatedOpacity( + opacity: selectedStore == null ? 0 : 1, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: child, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + AnimatedScale( + scale: isReady ? 1 : 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInCubic, + alignment: Alignment.bottomRight, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + ), + margin: const EdgeInsets.only(right: 12, left: 32), + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(maxWidth: 500), + child: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "You must abide by your tile server's Terms of Service when bulk downloading. 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.", + textAlign: TextAlign.end, + style: TextStyle(color: Colors.black), + ), + SizedBox(height: 8), + Icon(Icons.report, color: Colors.red, size: 32), + ], + ), + ), + ), + const SizedBox(height: 16), + FloatingActionButton.extended( + onPressed: () async { + final configureDownloadProvider = + context.read(); + + if (!isReady) { + configureDownloadProvider.isReady = true; + return; + } + + final regionSelectionProvider = + context.read(); + final downloadingProvider = + context.read(); + + final navigator = Navigator.of(context); + + final metadata = await regionSelectionProvider + .selectedStore!.metadata.read; + + downloadingProvider.setDownloadProgress( + regionSelectionProvider.selectedStore!.download + .startForeground( + region: region.toDownloadable( + minZoom: minZoom, + maxZoom: maxZoom, + start: startTile, + end: endTile, + options: TileLayer( + urlTemplate: metadata['sourceURL'], + userAgentPackageName: + 'dev.jaffaketchup.fmtc.demo', + ), + ), + parallelThreads: + configureDownloadProvider.parallelThreads, + maxBufferLength: + configureDownloadProvider.maxBufferLength, + skipExistingTiles: + configureDownloadProvider.skipExistingTiles, + skipSeaTiles: configureDownloadProvider.skipSeaTiles, + rateLimit: configureDownloadProvider.rateLimit, + ) + .asBroadcastStream(), + ); + configureDownloadProvider.isReady = false; + + navigator.pop(); + }, + label: const Text('Start Download'), + icon: Icon(isReady ? Icons.save : Icons.arrow_forward), + ), + ], + ), + ), + ); +} diff --git a/example/lib/src/screens/configure_download/components/store_selector.dart b/example/lib/src/screens/configure_download/components/store_selector.dart new file mode 100644 index 00000000..e63a6fbb --- /dev/null +++ b/example/lib/src/screens/configure_download/components/store_selector.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../../shared/state/general_provider.dart'; +import '../../home/map_view/state/region_selection_provider.dart'; + +class StoreSelector extends StatefulWidget { + const StoreSelector({super.key}); + + @override + State createState() => _StoreSelectorState(); +} + +class _StoreSelectorState extends State { + @override + Widget build(BuildContext context) => Row( + children: [ + const Text('Store'), + const Spacer(), + IntrinsicWidth( + child: Consumer2( + builder: (context, downloadProvider, generalProvider, _) => + FutureBuilder>( + future: FMTCRoot.stats.storesAvailable, + builder: (context, snapshot) => DropdownButton( + items: snapshot.data + ?.map( + (e) => DropdownMenuItem( + value: e, + child: Text(e.storeName), + ), + ) + .toList(), + onChanged: (store) => + downloadProvider.setSelectedStore(store), + value: downloadProvider.selectedStore ?? + (generalProvider.currentStores.length == 1 + ? null + : FMTCStore(generalProvider.currentStores.single)), + hint: Text( + snapshot.data == null + ? 'Loading...' + : snapshot.data!.isEmpty + ? 'None Available' + : 'None Selected', + ), + padding: const EdgeInsets.only(left: 12), + ), + ), + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/configure_download/configure_download.dart b/example/lib/src/screens/configure_download/configure_download.dart new file mode 100644 index 00000000..7ed25b95 --- /dev/null +++ b/example/lib/src/screens/configure_download/configure_download.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../shared/misc/exts/interleave.dart'; +import 'components/numerical_input_row.dart'; +import 'components/options_pane.dart'; +import 'components/region_information.dart'; +import 'components/start_download_button.dart'; +import 'components/store_selector.dart'; +import 'state/configure_download_provider.dart'; + +class ConfigureDownloadPopup extends StatelessWidget { + const ConfigureDownloadPopup({ + super.key, + required this.region, + required this.minZoom, + required this.maxZoom, + required this.startTile, + required this.endTile, + }); + + final BaseRegion region; + final int minZoom; + final int maxZoom; + final int startTile; + final int? endTile; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Configure Bulk Download')), + floatingActionButton: StartDownloadButton( + region: region, + minZoom: minZoom, + maxZoom: maxZoom, + startTile: startTile, + endTile: endTile, + ), + body: Stack( + fit: StackFit.expand, + children: [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox.shrink(), + RegionInformation( + region: region, + minZoom: minZoom, + maxZoom: maxZoom, + startTile: startTile, + endTile: endTile, + ), + const Divider(thickness: 2, height: 8), + const OptionsPane( + label: 'STORE DIRECTORY', + children: [StoreSelector()], + ), + OptionsPane( + label: 'PERFORMANCE FACTORS', + children: [ + NumericalInputRow( + label: 'Parallel Threads', + suffixText: 'threads', + value: (provider) => provider.parallelThreads, + min: 1, + max: 10, + onChanged: (provider, value) => + provider.parallelThreads = value, + ), + NumericalInputRow( + label: 'Rate Limit', + suffixText: 'max. tps', + value: (provider) => provider.rateLimit, + min: 1, + max: 300, + maxEligibleTilesPreview: 20, + onChanged: (provider, value) => + provider.rateLimit = value, + ), + NumericalInputRow( + label: 'Tile Buffer Length', + suffixText: 'max. tiles', + value: (provider) => provider.maxBufferLength, + min: 0, + max: null, + onChanged: (provider, value) => + provider.maxBufferLength = value, + ), + ], + ), + OptionsPane( + label: 'SKIP TILES', + children: [ + Row( + children: [ + const Text('Skip Existing Tiles'), + const Spacer(), + Switch.adaptive( + value: context + .select( + (provider) => provider.skipExistingTiles, + ), + onChanged: (val) => context + .read() + .skipExistingTiles = val, + activeColor: + Theme.of(context).colorScheme.primary, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Skip Sea Tiles'), + const Spacer(), + Switch.adaptive( + value: context.select((provider) => provider.skipSeaTiles), + onChanged: (val) => context + .read() + .skipSeaTiles = val, + activeColor: + Theme.of(context).colorScheme.primary, + ), + ], + ), + ], + ), + const SizedBox(height: 72), + ].interleave(const SizedBox.square(dimension: 16)).toList(), + ), + ), + ), + Selector( + selector: (context, provider) => provider.isReady, + builder: (context, isReady, _) => IgnorePointer( + ignoring: !isReady, + child: GestureDetector( + onTap: isReady + ? () => context + .read() + .isReady = false + : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInCubic, + color: isReady + ? Colors.black.withOpacity(2 / 3) + : Colors.transparent, + ), + ), + ), + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/configure_download/state/configure_download_provider.dart b/example/lib/src/screens/configure_download/state/configure_download_provider.dart new file mode 100644 index 00000000..5afffaa2 --- /dev/null +++ b/example/lib/src/screens/configure_download/state/configure_download_provider.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; + +class ConfigureDownloadProvider extends ChangeNotifier { + static const defaultValues = ( + parallelThreads: 3, + rateLimit: 200, + maxBufferLength: 200, + ); + + int _parallelThreads = defaultValues.parallelThreads; + int get parallelThreads => _parallelThreads; + set parallelThreads(int newNum) { + _parallelThreads = newNum; + notifyListeners(); + } + + int _rateLimit = defaultValues.rateLimit; + int get rateLimit => _rateLimit; + set rateLimit(int newNum) { + _rateLimit = newNum; + notifyListeners(); + } + + int _maxBufferLength = defaultValues.maxBufferLength; + int get maxBufferLength => _maxBufferLength; + set maxBufferLength(int newNum) { + _maxBufferLength = newNum; + notifyListeners(); + } + + bool _skipExistingTiles = true; + bool get skipExistingTiles => _skipExistingTiles; + set skipExistingTiles(bool newState) { + _skipExistingTiles = newState; + notifyListeners(); + } + + bool _skipSeaTiles = true; + bool get skipSeaTiles => _skipSeaTiles; + set skipSeaTiles(bool newState) { + _skipSeaTiles = newState; + notifyListeners(); + } + + bool _isReady = false; + bool get isReady => _isReady; + set isReady(bool newState) { + _isReady = newState; + notifyListeners(); + } +} diff --git a/example/lib/src/screens/home/config_panel/components/stores_list.dart b/example/lib/src/screens/home/config_panel/components/stores_list.dart deleted file mode 100644 index e2c0d17b..00000000 --- a/example/lib/src/screens/home/config_panel/components/stores_list.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'dart:collection'; - -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../../../shared/misc/exts/size_formatter.dart'; -import '../../../../shared/state/general_provider.dart'; - -class StoresList extends StatefulWidget { - const StoresList({ - super.key, - }); - - @override - State createState() => _StoresListState(); -} - -class _StoresListState extends State { - late final storesStream = - FMTCRoot.stats.watchStores(triggerImmediately: true).asyncMap( - (_) async { - final stores = await FMTCRoot.stats.storesAvailable; - return HashMap.fromEntries( - stores.map( - (store) => MapEntry( - store, - ( - stats: store.stats.all, - metadata: store.metadata.read, - ), - ), - ), - ); - }, - ); - - @override - Widget build(BuildContext context) => StreamBuilder( - stream: storesStream, - builder: (context, snapshot) { - if (snapshot.data == null) { - return const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: CircularProgressIndicator.adaptive(), - ), - ); - } - - final stores = snapshot.data!; - - if (stores.isEmpty) { - return SliverFillRemaining( - hasScrollBody: false, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 32, vertical: 12), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.folder_off, size: 42), - const SizedBox(height: 12), - Text( - 'Homes for tiles', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 6), - const Text( - 'Tiles belong to one or more stores, so create a store to ' - 'get started', - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - FilledButton.icon( - onPressed: () {}, - icon: const Icon(Icons.create_new_folder), - label: const Text('Create new store'), - ), - ], - ), - ), - ), - ); - } - - return SliverList.builder( - itemCount: stores.length + 1, - itemBuilder: (context, index) { - if (index == stores.length) { - return Material( - color: Colors.transparent, - child: ListTile( - title: const Text('Create new store'), - onTap: () {}, - leading: const SizedBox.square( - dimension: 56, - child: Center(child: Icon(Icons.create_new_folder)), - ), - ), - ); - } - - final store = stores.keys.elementAt(index); - final stats = stores.values.elementAt(index).stats; - //final metadata = stores.values.elementAt(index).metadata; - - return Material( - color: Colors.transparent, - child: Consumer( - builder: (context, provider, _) { - final isSelected = - provider.currentStores.contains(store.storeName) && - provider.storesSelectionMode == false; - - return ListTile( - title: Text(store.storeName), - subtitle: FutureBuilder( - future: stats, - builder: (context, snapshot) { - if (snapshot.data == null) { - return const Text('Loading stats...'); - } - - return Text( - '${snapshot.data!.size.asReadableSize} | ${snapshot.data!.length} tiles', - ); - }, - ), - leading: SizedBox.square( - dimension: 56, - child: Stack( - fit: StackFit.expand, - alignment: Alignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(16), - child: FutureBuilder( - future: store.stats.tileImage(size: 56), - builder: (context, snapshot) { - if (snapshot.data != null) { - return snapshot.data!; - } - return const ColoredBox(color: Colors.white); - }, - ), - ), - Center( - child: SizedBox.square( - dimension: 24, - child: AnimatedOpacity( - opacity: isSelected ? 1 : 0, - duration: const Duration(milliseconds: 100), - curve: isSelected - ? Curves.easeIn - : Curves.easeOut, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(99), - ), - child: const Center( - child: Icon( - Icons.check, - color: Colors.white, - size: 20, - ), - ), - ), - ), - ), - ), - ], - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.delete), - onPressed: () {}, - ), - IconButton( - icon: const Icon(Icons.edit), - onPressed: () {}, - ), - ], - ), - onTap: provider.storesSelectionMode == false - ? () { - if (isSelected) { - context - .read() - .removeStore(store.storeName); - } else { - context - .read() - .addStore(store.storeName); - } - } - : null, - ); - }, - ), - ); - }, - ); - }, - ); -} diff --git a/example/lib/src/screens/home/config_panel/map_config.dart b/example/lib/src/screens/home/config_panel/map_config.dart deleted file mode 100644 index 840c58b7..00000000 --- a/example/lib/src/screens/home/config_panel/map_config.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:flutter/material.dart' hide BottomSheet; -import 'package:provider/provider.dart'; - -import '../../../shared/state/general_provider.dart'; -import 'components/stores_list.dart'; - -class MapConfig extends StatefulWidget { - const MapConfig({ - super.key, - this.controller, - this.leading = const [], - }); - - final ScrollController? controller; - final List leading; - - @override - State createState() => _MapConfigState(); -} - -class _MapConfigState extends State { - final urlTextController = TextEditingController( - text: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - ); - - @override - Widget build(BuildContext context) => CustomScrollView( - controller: widget.controller, - slivers: [ - ...widget.leading, - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Text( - 'Configuration', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Selector( - selector: (context, provider) => provider.storesSelectionMode, - builder: (context, storesSelectionMode, _) => SegmentedButton( - segments: const [ - ButtonSegment( - value: null, - icon: Icon(Icons.deselect), - label: Text('Disabled'), - ), - ButtonSegment( - value: true, - icon: Icon(Icons.select_all), - label: Text('Use All'), - ), - ButtonSegment( - value: false, - icon: Icon(Icons.highlight_alt), - label: Text('Manual'), - ), - ], - selected: {storesSelectionMode}, - onSelectionChanged: (value) => context - .read() - .storesSelectionMode = value.single, - style: const ButtonStyle( - visualDensity: VisualDensity.comfortable, - ), - ), - ), - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 8)), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: LayoutBuilder( - builder: (context, constraints) => SizedBox( - width: constraints.maxWidth, - child: DropdownMenu( - controller: urlTextController, - width: constraints.maxWidth, - enableFilter: true, - requestFocusOnTap: true, - leadingIcon: const Icon(Icons.link), - label: const Text('URL Template'), - inputDecorationTheme: const InputDecorationTheme( - filled: true, - //contentPadding: EdgeInsets.symmetric(vertical: 5.0), - ), - /*onSelected: (String? urlTemplate) { - setState(() { - selectedIcon = icon; - }); - },*/ - dropdownMenuEntries: [ - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - 'b', - 'ab', - ] - .map( - (urlTemplate) => DropdownMenuEntry( - value: urlTemplate, - label: urlTemplate, - labelWidget: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(urlTemplate), - const Text( - 'Used by: x', - style: TextStyle(fontStyle: FontStyle.italic), - ), - ], - ), - ), - ) - .toList(), - ), - ), - ), - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 12)), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Text( - 'Stores', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ), - const StoresList(), - ], - ); -} diff --git a/example/lib/src/screens/home/config_panel/wrappers/side_panel/side_panel.dart b/example/lib/src/screens/home/config_panel/wrappers/side_panel/side_panel.dart deleted file mode 100644 index 7c3ebb5a..00000000 --- a/example/lib/src/screens/home/config_panel/wrappers/side_panel/side_panel.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../map_config.dart'; - -class MapConfigSidePanel extends StatelessWidget { - const MapConfigSidePanel({ - super.key, - required this.selectedTab, - }); - - final int selectedTab; - - @override - Widget build(BuildContext context) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeOut, - transitionBuilder: (child, animation) => SizeTransition( - sizeFactor: animation, - axis: Axis.horizontal, - child: child, - ), - child: selectedTab == 0 - ? Container( - margin: const EdgeInsets.only( - right: 16, - top: 16, - bottom: 16, - ), - width: 380, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.surfaceContainer, - ), - child: MapConfig( - leading: [ - SliverPadding( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 8, - ), - sliver: SliverToBoxAdapter( - child: Row( - children: [ - Text( - 'Stores & Config', - style: Theme.of(context).textTheme.titleLarge, - ), - const Spacer(), - IconButton( - onPressed: () {}, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.help_outline), - ), - ], - ), - ), - ), - ], - ), - ) - : const SizedBox.shrink(), - ); -} diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/bottom_sheet.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart similarity index 80% rename from example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/bottom_sheet.dart rename to example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart index f3375ea5..d62717dd 100644 --- a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/bottom_sheet.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart @@ -1,25 +1,29 @@ import 'package:flutter/material.dart'; import '../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; +import '../../panels/behaviour/behaviour.dart'; +import '../../panels/map/map.dart'; +import '../../panels/stores/stores.dart'; import 'components/scrollable_provider.dart'; +import 'components/tab_header.dart'; -class BottomSheet extends StatefulWidget { - const BottomSheet({ +part 'components/contents.dart'; + +class ConfigViewBottomSheet extends StatefulWidget { + const ConfigViewBottomSheet({ super.key, required this.controller, - required this.child, }); final DraggableScrollableController controller; - final Widget child; static const topPadding = kMinInteractiveDimension / 1.5; @override - State createState() => _BottomSheetState(); + State createState() => _ConfigViewBottomSheetState(); } -class _BottomSheetState extends State { +class _ConfigViewBottomSheetState extends State { @override Widget build(BuildContext context) { final screenTopPadding = @@ -75,16 +79,14 @@ class _BottomSheetState extends State { curve: Curves.easeInOut, color: innerController.hasClients && innerController.offset != 0 - ? Theme.of(context) - .colorScheme - .surfaceContainerLowest - : Theme.of(context).colorScheme.surfaceContainerLow, + ? Theme.of(context).colorScheme.surfaceContainer + : Theme.of(context).colorScheme.surface, ), ), ), Expanded( child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLow, + color: Theme.of(context).colorScheme.surface, child: child, ), ), @@ -94,9 +96,16 @@ class _BottomSheetState extends State { }, child: Stack( children: [ + // Future proofing if child is moved out: avoid dependency + // injection, as that may not be possible in future BottomSheetScrollableProvider( innerScrollController: innerController, - child: widget.child, + child: SizedBox( + width: double.infinity, + child: _ContentPanels( + bottomSheetOuterController: widget.controller, + ), + ), ), IgnorePointer( child: DelayedControllerAttachmentBuilder( @@ -106,13 +115,14 @@ class _BottomSheetState extends State { return const SizedBox.shrink(); } - final calcHeight = BottomSheet.topPadding - + final calcHeight = ConfigViewBottomSheet.topPadding - (screenTopPadding - constraints.maxHeight + widget.controller.pixels); return SizedBox( - height: calcHeight.clamp(0, BottomSheet.topPadding), + height: + calcHeight.clamp(0, ConfigViewBottomSheet.topPadding), width: constraints.maxWidth, child: Semantics( label: MaterialLocalizations.of(context) diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart similarity index 60% rename from example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart rename to example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart index f153678c..285821ac 100644 --- a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart @@ -1,13 +1,7 @@ -import 'package:flutter/material.dart' hide BottomSheet; +part of '../bottom_sheet.dart'; -import '../../../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; -import '../../../../map_config.dart'; -import '../../bottom_sheet.dart'; -import '../../components/scrollable_provider.dart'; -import 'components/tab_header.dart'; - -class StoresAndConfigureTab extends StatefulWidget { - const StoresAndConfigureTab({ +class _ContentPanels extends StatefulWidget { + const _ContentPanels({ super.key, required this.bottomSheetOuterController, }); @@ -15,23 +9,19 @@ class StoresAndConfigureTab extends StatefulWidget { final DraggableScrollableController bottomSheetOuterController; @override - State createState() => _StoresAndConfigureTabState(); + State<_ContentPanels> createState() => _ContentPanelsState(); } -class _StoresAndConfigureTabState extends State { - final urlTextController = TextEditingController( - text: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - ); - +class _ContentPanelsState extends State<_ContentPanels> { @override Widget build(BuildContext context) { final screenTopPadding = MediaQueryData.fromView(View.of(context)).padding.top; - return MapConfig( + return CustomScrollView( controller: BottomSheetScrollableProvider.innerScrollControllerOf(context), - leading: [ + slivers: [ SliverToBoxAdapter( child: DelayedControllerAttachmentBuilder( listenable: widget.bottomSheetOuterController, @@ -48,13 +38,13 @@ class _StoresAndConfigureTabState extends State { final oldMin = maxHeight - screenTopPadding; const maxTopPadding = 0.0; - const minTopPadding = BottomSheet.topPadding - 8; + const minTopPadding = ConfigViewBottomSheet.topPadding - 8; final double topPaddingHeight = ((((oldValue - oldMin) * (maxTopPadding - minTopPadding)) / (oldMax - oldMin)) + minTopPadding) - .clamp(0.0, BottomSheet.topPadding - 8); + .clamp(0.0, ConfigViewBottomSheet.topPadding - 8); return SizedBox(height: topPaddingHeight); }, @@ -63,6 +53,18 @@ class _StoresAndConfigureTabState extends State { TabHeader( bottomSheetOuterController: widget.bottomSheetOuterController, ), + const SliverToBoxAdapter(child: SizedBox(height: 6)), + const SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter(child: ConfigPanelBehaviour()), + ), + const SliverToBoxAdapter(child: Divider()), + const SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter(child: ConfigPanelMap()), + ), + const SliverToBoxAdapter(child: Divider()), + const ConfigPanelStoresSliver(), ], ); } diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/scrollable_provider.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/scrollable_provider.dart similarity index 100% rename from example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/scrollable_provider.dart rename to example/lib/src/screens/home/config_view/forms/bottom_sheet/components/scrollable_provider.dart diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/components/tab_header.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/tab_header.dart similarity index 94% rename from example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/components/tab_header.dart rename to example/lib/src/screens/home/config_view/forms/bottom_sheet/components/tab_header.dart index dc669f36..92aaa882 100644 --- a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/tabs/stores/components/tab_header.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/tab_header.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../../../../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; -import '../../../components/scrollable_provider.dart'; +import '../../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; +import 'scrollable_provider.dart'; class TabHeader extends StatelessWidget { const TabHeader({ @@ -57,8 +57,8 @@ class TabHeader extends StatelessWidget { duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, color: innerScrollController.offset != 0 - ? Theme.of(context).colorScheme.surfaceContainerLowest - : Theme.of(context).colorScheme.surfaceContainerLow, + ? Theme.of(context).colorScheme.surfaceContainer + : Theme.of(context).colorScheme.surface, child: child, ), child: Padding( diff --git a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/toolbar.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/toolbar.dart similarity index 93% rename from example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/toolbar.dart rename to example/lib/src/screens/home/config_view/forms/bottom_sheet/components/toolbar.dart index 77317921..5c72b65f 100644 --- a/example/lib/src/screens/home/config_panel/wrappers/bottom_sheet/components/toolbar.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/toolbar.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' hide BottomSheet; +import 'package:flutter/material.dart'; import '../bottom_sheet.dart'; @@ -21,7 +21,7 @@ class BottomSheetToolbar extends StatelessWidget { AnimatedBuilder( animation: bottomSheetOuterController, builder: (context, child) => SizedBox( - height: BottomSheet.topPadding - + height: ConfigViewBottomSheet.topPadding - _calcVisibility(bottomSheetOuterController.size, 16), ), ), diff --git a/example/lib/src/screens/home/config_view/forms/side/side.dart b/example/lib/src/screens/home/config_view/forms/side/side.dart new file mode 100644 index 00000000..e0e0445b --- /dev/null +++ b/example/lib/src/screens/home/config_view/forms/side/side.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import '../../panels/behaviour/behaviour.dart'; +import '../../panels/map/map.dart'; +import '../../panels/stores/stores.dart'; + +class ConfigViewSide extends StatelessWidget { + const ConfigViewSide({ + super.key, + required this.selectedTab, + }); + + final int selectedTab; + + @override + Widget build(BuildContext context) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + transitionBuilder: (child, animation) => SizeTransition( + axis: Axis.horizontal, + axisAlignment: 1, // Align right + sizeFactor: animation, + child: child, + ), + child: + selectedTab == 0 ? const _ContentPanels() : const SizedBox.shrink(), + ); +} + +class _ContentPanels extends StatelessWidget { + const _ContentPanels(); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(right: 16, top: 16), + child: SizedBox( + width: 450, + child: Column( + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99), + color: Theme.of(context).colorScheme.surface, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Row( + children: [ + Text( + 'Stores & Config', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.help_outline), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(16), + width: double.infinity, + child: const ConfigPanelBehaviour(), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(16), + width: double.infinity, + child: const ConfigPanelMap(), + ), + const SizedBox(height: 16), + Expanded( + child: 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), + sliver: ConfigPanelStoresSliver(), + ), + ], + ), + ), + ), + ], + ), + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/forms/side_panel/side.dart b/example/lib/src/screens/home/config_view/forms/side_panel/side.dart new file mode 100644 index 00000000..b8a489be --- /dev/null +++ b/example/lib/src/screens/home/config_view/forms/side_panel/side.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import '../../panels/behaviour/behaviour.dart'; +import '../../panels/map/map.dart'; +import '../../panels/stores/stores.dart'; + +class ConfigViewSide extends StatelessWidget { + const ConfigViewSide({ + super.key, + required this.selectedTab, + }); + + final int selectedTab; + + @override + Widget build(BuildContext context) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + transitionBuilder: (child, animation) => SizeTransition( + axis: Axis.horizontal, + axisAlignment: 1, // Align right + sizeFactor: animation, + child: child, + ), + child: + selectedTab == 0 ? const _ContentPanels() : const SizedBox.shrink(), + ); +} + +class _ContentPanels extends StatelessWidget { + const _ContentPanels(); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(right: 16, top: 16), + child: SizedBox( + width: 450, + child: Column( + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99), + color: Theme.of(context).colorScheme.surface, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Row( + children: [ + Text( + 'Stores & Config', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.help_outline), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(16), + width: double.infinity, + child: const ConfigPanelBehaviour(), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(16), + width: double.infinity, + child: const ConfigPanelMap(), + ), + const SizedBox(height: 16), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints.expand(), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + color: Theme.of(context).colorScheme.surface, + ), + child: const ConfigPanelStoresSliver(), + ), + ), + ), + ], + ), + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart b/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart new file mode 100644 index 00000000..45bd4ef6 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../shared/state/general_provider.dart'; + +class ConfigPanelBehaviour extends StatelessWidget { + const ConfigPanelBehaviour({ + super.key, + }); + + @override + Widget build(BuildContext context) => Selector( + selector: (context, provider) => provider.behaviourPrimary, + builder: (context, behaviourPrimary, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: null, + icon: Icon(Icons.download_for_offline_outlined), + label: Text('Cache Only'), + ), + ButtonSegment( + value: false, + icon: Icon(Icons.storage_rounded), + label: Text('Cache'), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.public_rounded), + label: Text('Network'), + ), + ], + selected: {behaviourPrimary}, + onSelectionChanged: (value) => context + .read() + .behaviourPrimary = value.single, + style: const ButtonStyle( + visualDensity: VisualDensity.comfortable, + ), + ), + ), + const SizedBox(height: 6), + Selector( + selector: (context, provider) => + provider.behaviourUpdateFromNetwork, + builder: (context, behaviourUpdateFromNetwork, _) => Row( + children: [ + const SizedBox(width: 8), + const Text('Update cache when network used'), + const Spacer(), + Switch.adaptive( + value: + behaviourPrimary != null && behaviourUpdateFromNetwork, + onChanged: behaviourPrimary == null + ? null + : (value) => context + .read() + .behaviourUpdateFromNetwork = value, + thumbIcon: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? const Icon(Icons.edit) + : const Icon(Icons.edit_off), + ), + ), + const SizedBox(width: 8), + ], + ), + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/map/map.dart b/example/lib/src/screens/home/config_view/panels/map/map.dart new file mode 100644 index 00000000..f68bb13d --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/map/map.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../shared/components/url_selector.dart'; +import '../../../../../shared/state/general_provider.dart'; + +class ConfigPanelMap extends StatelessWidget { + const ConfigPanelMap({ + super.key, + }); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + URLSelector( + initialValue: context.select( + (provider) => provider.urlTemplate, + ), + onSelected: (urlTemplate) => + context.read().urlTemplate = urlTemplate, + ), + const SizedBox(height: 6), + Selector( + selector: (context, provider) => provider.displayDebugOverlay, + builder: (context, displayDebugOverlay, _) => Row( + children: [ + const SizedBox(width: 8), + const Text('Display debug/info tile overlay'), + const Spacer(), + Switch.adaptive( + value: displayDebugOverlay, + onChanged: (value) => context + .read() + .displayDebugOverlay = value, + thumbIcon: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? const Icon(Icons.layers) + : const Icon(Icons.layers_clear), + ), + ), + const SizedBox(width: 8), + ], + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/list.dart b/example/lib/src/screens/home/config_view/panels/stores/components/list.dart new file mode 100644 index 00000000..65df4042 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/list.dart @@ -0,0 +1,76 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +import 'new_store_button.dart'; +import 'no_stores.dart'; +import 'store_tile.dart'; + +class StoresList extends StatefulWidget { + const StoresList({ + super.key, + }); + + @override + State createState() => _StoresListState(); +} + +class _StoresListState extends State { + late final storesStream = + FMTCRoot.stats.watchStores(triggerImmediately: true).asyncMap( + (_) async { + final stores = await FMTCRoot.stats.storesAvailable; + return HashMap.fromEntries( + stores.map( + (store) => MapEntry( + store, + ( + stats: store.stats.all, + metadata: store.metadata.read, + tileImage: store.stats.tileImage(size: 56), + ), + ), + ), + ); + }, + ); + + @override + Widget build(BuildContext context) => StreamBuilder( + stream: storesStream, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ); + } + + final stores = snapshot.data!; + + if (stores.isEmpty) return const NoStores(); + + return SliverList.builder( + itemCount: stores.length + 1, + itemBuilder: (context, index) { + if (index == 0) return const NewStoreButton(); + + final store = stores.keys.elementAt(index - 1); + final stats = stores.values.elementAt(index - 1).stats; + final metadata = stores.values.elementAt(index - 1).metadata; + final tileImage = stores.values.elementAt(index - 1).tileImage; + + return StoreTile( + store: store, + stats: stats, + metadata: metadata, + tileImage: tileImage, + ); + }, + ); + }, + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart b/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart new file mode 100644 index 00000000..10e07159 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../../../../../store_editor/store_editor.dart'; + +class NewStoreButton extends StatelessWidget { + const NewStoreButton({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: SizedBox( + height: double.infinity, + child: FilledButton.tonalIcon( + label: const Text('Create new store'), + icon: const Icon(Icons.create_new_folder), + onPressed: () => + Navigator.of(context).pushNamed(StoreEditorPopup.route), + ), + ), + ), + const SizedBox(width: 8), + IconButton.outlined( + icon: const Icon(Icons.file_open), + tooltip: 'Import store', + onPressed: () {}, + ), + ], + ), + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart b/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart new file mode 100644 index 00000000..5b67da42 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import '../../../../../store_editor/store_editor.dart'; + +class NoStores extends StatelessWidget { + const NoStores({ + 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.folder_off, size: 42), + const SizedBox(height: 12), + Text( + 'Homes for tiles', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + const Text( + 'Tiles belong to one or more stores, but it looks like you ' + "don't have one yet!\nCreate or import one to get started.", + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => + Navigator.of(context).pushNamed(StoreEditorPopup.route), + icon: const Icon(Icons.create_new_folder), + label: const Text('Create new store'), + ), + ), + const SizedBox(height: 6), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.file_open), + label: const Text('Import a store'), + ), + ), + ], + ), + ), + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart new file mode 100644 index 00000000..25639525 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../shared/misc/exts/size_formatter.dart'; +import '../../../../../../shared/misc/store_metadata_keys.dart'; +import '../../../../../../shared/state/general_provider.dart'; +import '../../../../../store_editor/store_editor.dart'; + +class StoreTile extends StatelessWidget { + const StoreTile({ + super.key, + required this.store, + required this.stats, + required this.metadata, + required this.tileImage, + }); + + final FMTCStore store; + final Future<({int hits, int length, int misses, double size})> stats; + final Future> metadata; + final Future tileImage; + + @override + Widget build(BuildContext context) => Material( + color: Colors.transparent, + child: Consumer( + builder: (context, provider, _) { + final isSelected = + provider.currentStores.contains(store.storeName) && + provider.storesSelectionMode == false; + + return FutureBuilder( + future: metadata, + builder: (context, metadataSnapshot) { + final matchesUrl = metadataSnapshot.data == null + ? null + : provider.urlTemplate == + metadataSnapshot + .data![StoreMetadataKeys.urlTemplate.key]; + + final inUse = provider.storesSelectionMode != null && + (matchesUrl ?? false) && + (provider.storesSelectionMode! || isSelected); + + return ListTile( + title: Text(store.storeName), + enabled: (provider.storesSelectionMode ?? true) || + (matchesUrl ?? false), + subtitle: FutureBuilder( + future: stats, + builder: (context, statsSnapshot) { + if (statsSnapshot.data case final stats?) { + final statsPart = + '${stats.size.asReadableSize} | ${stats.length} tiles'; + + final usagePart = provider.storesSelectionMode == null + ? '' + : (matchesUrl ?? false) + ? (provider.storesSelectionMode ?? true) || + isSelected + ? '\nIn use' + : '\nNot in use' + : '\nSource mismatch'; + + return Text(statsPart + usagePart); + } + + return const Text('Loading stats...\nLoading usage...'); + }, + ), + leading: SizedBox.square( + dimension: 56, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: FutureBuilder( + future: tileImage, + builder: (context, snapshot) { + if (snapshot.data case final data?) return data; + return const Icon(Icons.filter_none); + }, + ), + ), + Center( + child: SizedBox.square( + dimension: 24, + child: AnimatedOpacity( + opacity: inUse ? 1 : 0, + duration: const Duration(milliseconds: 100), + curve: inUse ? Curves.easeIn : Curves.easeOut, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(99), + ), + child: const Center( + child: Icon( + Icons.check, + color: Colors.white, + size: 20, + ), + ), + ), + ), + ), + ), + ], + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => Navigator.of(context).pushNamed( + StoreEditorPopup.route, + arguments: store.storeName, + ), + ), + ], + ), + onTap: provider.storesSelectionMode == false + ? () { + if (isSelected) { + context + .read() + .removeStore(store.storeName); + } else { + context + .read() + .addStore(store.storeName); + } + } + : null, + ); + }, + ); + }, + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/stores.dart b/example/lib/src/screens/home/config_view/panels/stores/stores.dart new file mode 100644 index 00000000..cc24d6df --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/stores.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../shared/state/general_provider.dart'; +import 'components/list.dart'; + +class ConfigPanelStoresSliver extends StatelessWidget { + const ConfigPanelStoresSliver({ + super.key, + }); + + @override + Widget build(BuildContext context) => SliverMainAxisGroup( + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: Selector( + selector: (context, provider) => provider.storesSelectionMode, + builder: (context, storesSelectionMode, _) => SegmentedButton( + segments: const [ + ButtonSegment( + value: null, + icon: Icon(Icons.deselect_rounded), + label: Text('Disabled'), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.select_all_rounded), + label: Text('Automatic'), + ), + ButtonSegment( + value: false, + icon: Icon(Icons.highlight_alt_rounded), + label: Text('Manual'), + ), + ], + selected: {storesSelectionMode}, + onSelectionChanged: (value) => context + .read() + .storesSelectionMode = value.single, + style: const ButtonStyle( + visualDensity: VisualDensity.comfortable, + ), + ), + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 6)), + const StoresList(), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ); +} diff --git a/example/lib/src/screens/home/home.dart b/example/lib/src/screens/home/home.dart index 554e742d..c9f0be6a 100644 --- a/example/lib/src/screens/home/home.dart +++ b/example/lib/src/screens/home/home.dart @@ -1,8 +1,7 @@ -import 'package:flutter/material.dart' hide BottomSheet; +import 'package:flutter/material.dart'; -import 'config_panel/wrappers/bottom_sheet/bottom_sheet.dart'; -import 'config_panel/wrappers/bottom_sheet/tabs/stores/stores.dart'; -import 'config_panel/wrappers/side_panel/side_panel.dart'; +import 'config_view/forms/bottom_sheet/bottom_sheet.dart'; +import 'config_view/forms/side/side.dart'; import 'map_view/bottom_sheet_wrapper.dart'; import 'map_view/map_view.dart'; @@ -18,28 +17,6 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { final bottomSheetOuterController = DraggableScrollableController(); - /*late final bottomSheetTabs = [ - StoresAndConfigureTab( - bottomSheetOuterController: bottomSheetOuterController, - ), - StatefulBuilder( - builder: (context, _) { - return CustomScrollView( - controller: - BottomSheetScrollableProvider.innerScrollControllerOf(context), - ); - }, - ), - StatefulBuilder( - builder: (context, _) { - return CustomScrollView( - controller: - BottomSheetScrollableProvider.innerScrollControllerOf(context), - ); - }, - ), - ];*/ - int selectedTab = 0; @override @@ -62,15 +39,8 @@ class _HomeScreenState extends State { mode: mapMode, layoutDirection: layoutDirection, ), - bottomSheet: BottomSheet( - controller: bottomSheetOuterController, - child: SizedBox( - width: double.infinity, - child: StoresAndConfigureTab( - bottomSheetOuterController: bottomSheetOuterController, - ), - ), - ), + bottomSheet: + ConfigViewBottomSheet(controller: bottomSheetOuterController), bottomNavigationBar: NavigationBar( selectedIndex: selectedTab, destinations: const [ @@ -132,10 +102,21 @@ class _HomeScreenState extends State { ); } - return Scaffold( - body: Row( + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + tween: Tween(begin: 0, end: selectedTab == 0 ? 0 : 1), + builder: (context, colorAnimation, child) => Scaffold( + backgroundColor: ColorTween( + begin: Theme.of(context).colorScheme.surfaceContainer, + end: Theme.of(context).scaffoldBackgroundColor, + ).lerp(colorAnimation), + body: child, + ), + child: Row( children: [ NavigationRail( + backgroundColor: Colors.transparent, destinations: const [ NavigationRailDestination( icon: Icon(Icons.map_outlined), @@ -169,7 +150,7 @@ class _HomeScreenState extends State { ), onDestinationSelected: (i) => setState(() => selectedTab = i), ), - MapConfigSidePanel(selectedTab: selectedTab), + ConfigViewSide(selectedTab: selectedTab), Expanded( child: ClipRRect( borderRadius: const BorderRadius.only( diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart index bd74167e..4e87d601 100644 --- a/example/lib/src/screens/home/map_view/map_view.dart +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -117,10 +117,8 @@ class _MapViewState extends State sharedPrefs.getDouble(SharedPrefsKeys.mapLocationLat.name) ?? 51.5216, sharedPrefs.getDouble(SharedPrefsKeys.mapLocationLng.name) ?? -0.6780, ), - initialZoom: sharedPrefs.getDouble( - SharedPrefsKeys.mapLocationZoom.name, - ) ?? - 12, + initialZoom: + sharedPrefs.getDouble(SharedPrefsKeys.mapLocationZoom.name) ?? 12, interactionOptions: const InteractionOptions( flags: InteractiveFlag.all & ~InteractiveFlag.rotate & @@ -247,118 +245,120 @@ class _MapViewState extends State return Selector>( selector: (context, provider) => provider.currentStores, - builder: (context, currentStores, _) => - FutureBuilder?>( - future: currentStores.isEmpty - ? Future.sync(() => {}) - : FMTCStore(currentStores.first).metadata.read, - builder: (context, metadata) { - if (!metadata.hasData || - metadata.data == null || - (currentStores.isNotEmpty && metadata.data!.isEmpty)) { - return const LoadingIndicator('Preparing map'); - } + builder: (context, currentStores, _) { + final map = FlutterMap( + mapController: mapController.mapController, + options: mapOptions, + children: [ + FutureBuilder?>( + future: currentStores.isEmpty + ? Future.sync(() => {}) + : FMTCStore(currentStores.first).metadata.read, + builder: (context, metadata) { + if (!metadata.hasData || + metadata.data == null || + (currentStores.isNotEmpty && metadata.data!.isEmpty)) { + return const AbsorbPointer( + child: LoadingIndicator('Preparing map'), + ); + } - final urlTemplate = currentStores.isNotEmpty && metadata.data != null - ? metadata.data!['sourceURL']! - : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + final urlTemplate = + currentStores.isNotEmpty && metadata.data != null + ? metadata.data!['sourceURL']! + : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; - final map = FlutterMap( - mapController: mapController.mapController, - options: mapOptions, - children: [ - TileLayer( - urlTemplate: urlTemplate, - userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', - maxNativeZoom: 20, - tileProvider: currentStores.isNotEmpty - ? FMTCStore(currentStores.first).getTileProvider( - settings: FMTCTileProviderSettings( - behavior: CacheBehavior.values - .byName(metadata.data!['behaviour']!), - cachedValidDuration: int.parse( - metadata.data!['validDuration']!, - ) == - 0 - ? Duration.zero - : Duration( - days: int.parse( - metadata.data!['validDuration']!, + return TileLayer( + urlTemplate: urlTemplate, + userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', + maxNativeZoom: 20, + tileProvider: currentStores.isNotEmpty + ? FMTCStore(currentStores.first).getTileProvider( + settings: FMTCTileProviderSettings( + behavior: CacheBehavior.values + .byName(metadata.data!['behaviour']!), + cachedValidDuration: int.parse( + metadata.data!['validDuration']!, + ) == + 0 + ? Duration.zero + : Duration( + days: int.parse( + metadata.data!['validDuration']!, + ), ), - ), - /*maxStoreLength: - int.parse(metadata.data!['maxLength']!),*/ - ), - ) - : NetworkTileProvider(), - ), - if (widget.mode == MapViewMode.regionSelect) ...[ - const RegionShape(), - const CustomPolygonSnappingIndicator(), - ], - if (widget.bottomPaddingWrapperBuilder != null) - Builder( - builder: (context) => widget.bottomPaddingWrapperBuilder!( - context, - _attributionLayer, - ), - ) - else - _attributionLayer, + /*maxStoreLength: + int.parse(metadata.data!['maxLength']!),*/ + ), + ) + : NetworkTileProvider(), + ); + }, + ), + if (widget.mode == MapViewMode.regionSelect) ...[ + const RegionShape(), + const CustomPolygonSnappingIndicator(), ], - ); + if (widget.bottomPaddingWrapperBuilder != null) + Builder( + builder: (context) => widget.bottomPaddingWrapperBuilder!( + context, + _attributionLayer, + ), + ) + else + _attributionLayer, + ], + ); - return LayoutBuilder( - builder: (context, constraints) { - final double sidePanelLeft = - switch ((widget.layoutDirection, widget.mode)) { - (Axis.vertical, _) => 0, - (Axis.horizontal, MapViewMode.standard) => -70, - (Axis.horizontal, MapViewMode.regionSelect) => 12, - }; - final double sidePanelBottom = - switch ((widget.layoutDirection, widget.mode)) { - (Axis.horizontal, _) => 0, - (Axis.vertical, MapViewMode.standard) => -70, - (Axis.vertical, MapViewMode.regionSelect) => 12, - }; - - return Stack( - fit: StackFit.expand, - children: [ - MouseRegion( - opaque: false, - cursor: widget.mode == MapViewMode.standard || - context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter - ? MouseCursor.defer - : context.select( - (p) => p.customPolygonSnap, - ) - ? SystemMouseCursors.none - : SystemMouseCursors.precise, - child: map, - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - left: sidePanelLeft, - bottom: sidePanelBottom, + return LayoutBuilder( + builder: (context, constraints) { + final double sidePanelLeft = + switch ((widget.layoutDirection, widget.mode)) { + (Axis.vertical, _) => 0, + (Axis.horizontal, MapViewMode.regionSelect) => 0, + (Axis.horizontal, MapViewMode.standard) => -85, + }; + final double sidePanelBottom = + switch ((widget.layoutDirection, widget.mode)) { + (Axis.horizontal, _) => 0, + (Axis.vertical, MapViewMode.regionSelect) => 0, + (Axis.vertical, MapViewMode.standard) => -85, + }; - // top: widget.layoutDirection == Axis.horizontal ? 12 : null, - // bottom: 12, - //start: widget.layoutDirection == Axis.horizontal ? 24 : 12, - //end: widget.layoutDirection == Axis.horizontal ? null : 12, - child: SizedBox( - height: widget.layoutDirection == Axis.horizontal - ? constraints.maxHeight - : null, - width: widget.layoutDirection == Axis.horizontal - ? null - : constraints.maxWidth, + return Stack( + fit: StackFit.expand, + children: [ + MouseRegion( + opaque: false, + cursor: widget.mode == MapViewMode.standard || + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter + ? MouseCursor.defer + : context.select( + (p) => p.customPolygonSnap, + ) + ? SystemMouseCursors.none + : SystemMouseCursors.precise, + child: map, + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + left: sidePanelLeft, + bottom: sidePanelBottom, + child: SizedBox( + height: widget.layoutDirection == Axis.horizontal + ? constraints.maxHeight + : null, + width: widget.layoutDirection == Axis.horizontal + ? null + : constraints.maxWidth, + child: Padding( + padding: const EdgeInsets.all(8), child: RegionSelectionSidePanel( layoutDirection: widget.layoutDirection, bottomPaddingWrapperBuilder: @@ -366,25 +366,22 @@ class _MapViewState extends State ), ), ), - if (widget.mode == MapViewMode.regionSelect && - context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter && - !context.select( - (p) => p.customPolygonSnap, - )) - const Center(child: Crosshairs()), - /*UsageInstructions( - layoutDirection: widget.layoutDirection, - ),*/ - ], - ); - }, - ); - }, - ), + ), + if (widget.mode == MapViewMode.regionSelect && + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter && + !context.select( + (p) => p.customPolygonSnap, + )) + const Center(child: Crosshairs()), + ], + ); + }, + ); + }, ); } } diff --git a/example/lib/src/screens/home/map_view/region_selection_components/custom_polygon_snapping_indicator.dart b/example/lib/src/screens/home/map_view/region_selection_components/custom_polygon_snapping_indicator.dart index cffb227a..c2d42f7b 100644 --- a/example/lib/src/screens/home/map_view/region_selection_components/custom_polygon_snapping_indicator.dart +++ b/example/lib/src/screens/home/map_view/region_selection_components/custom_polygon_snapping_indicator.dart @@ -22,16 +22,20 @@ class CustomPolygonSnappingIndicator extends StatelessWidget { (p) => p.customPolygonSnap, )) Marker( - height: 25, - width: 25, + height: 32, + width: 32, point: coords.first, child: DecoratedBox( decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(1028), + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(width: 2), ), child: const Center( - child: Icon(Icons.auto_awesome, size: 15), + child: Icon( + Icons.auto_fix_normal, + size: 18, + ), ), ), ), diff --git a/example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart b/example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart index e72c9ae2..f6aea01f 100644 --- a/example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart +++ b/example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart @@ -61,8 +61,6 @@ class RegionShape extends StatelessWidget { ) / 1000, ).toOutline().toList(); - case RegionType.line: - throw Error(); case RegionType.customPolygon: holePoints = provider.isCustomPolygonComplete ? provider.coordinates @@ -73,10 +71,13 @@ class RegionShape extends StatelessWidget { else provider.currentNewPointPos, ]; + case RegionType.line: + throw UnsupportedError('Unreachable.'); } } return PolygonLayer( + key: UniqueKey(), polygons: [ Polygon( points: [ @@ -85,7 +86,14 @@ class RegionShape extends StatelessWidget { const LatLng(90, -180), const LatLng(-90, -180), ], - holePointsList: [holePoints], + holePointsList: holePoints.length < 3 + ? null + : [ + if (Polygon.isClockwise(holePoints)) + holePoints + else + holePoints.reversed.toList(), + ], borderColor: Colors.black, borderStrokeWidth: 2, color: Theme.of(context).colorScheme.surface.withOpacity(0.5), diff --git a/example/lib/src/screens/home/map_view/region_selection_components/usage_instructions.dart b/example/lib/src/screens/home/map_view/region_selection_components/usage_instructions.dart deleted file mode 100644 index 047ac6a2..00000000 --- a/example/lib/src/screens/home/map_view/region_selection_components/usage_instructions.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:provider/provider.dart'; - -import '../state/region_selection_provider.dart'; - -class UsageInstructions extends StatelessWidget { - const UsageInstructions({ - super.key, - required this.layoutDirection, - }); - - final Axis layoutDirection; - - @override - Widget build(BuildContext context) => Align( - alignment: layoutDirection == Axis.vertical - ? Alignment.centerRight - : Alignment.topCenter, - child: Padding( - padding: EdgeInsets.only( - left: layoutDirection == Axis.vertical ? 0 : 24, - right: layoutDirection == Axis.vertical ? 164 : 24, - top: 24, - bottom: layoutDirection == Axis.vertical ? 24 : 0, - ), - child: FittedBox( - child: IgnorePointer( - child: DefaultTextStyle( - style: GoogleFonts.ubuntu( - fontSize: 20, - color: Colors.white, - ), - child: Consumer( - builder: (context, provider, _) => AnimatedOpacity( - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - opacity: provider.coordinates.isEmpty ? 1 : 0, - child: DecoratedBox( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.4), - spreadRadius: 50, - blurRadius: 90, - ), - ], - ), - child: Flex( - direction: layoutDirection, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: layoutDirection == Axis.vertical - ? CrossAxisAlignment.end - : CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - textDirection: layoutDirection == Axis.vertical - ? null - : TextDirection.rtl, - children: [ - Icon( - provider.regionSelectionMethod == - RegionSelectionMethod.usePointer - ? Icons.ads_click - : Icons.filter_center_focus, - size: 60, - ), - const SizedBox.square(dimension: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - AutoSizeText( - provider.regionSelectionMethod == - RegionSelectionMethod.usePointer - ? '@ Pointer' - : '@ Map Center', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - const SizedBox.square(dimension: 2), - AutoSizeText( - 'Tap/click to add ' - '${provider.regionType == RegionType.circle ? 'center' : 'point'}', - maxLines: 1, - ), - AutoSizeText( - provider.regionType == RegionType.circle - ? 'Tap/click again to set radius' - : 'Hold/right-click to remove last point', - maxLines: 1, - ), - ], - ), - ], - ), - ), - ), - ), - ), - ), - ), - ), - ); -} diff --git a/example/lib/src/screens/store_editor/store_editor.dart b/example/lib/src/screens/store_editor/store_editor.dart new file mode 100644 index 00000000..a1f88170 --- /dev/null +++ b/example/lib/src/screens/store_editor/store_editor.dart @@ -0,0 +1,310 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; +import 'package:validators/validators.dart' as validators; + +import '../../shared/components/loading_indicator.dart'; +import '../../shared/misc/store_metadata_keys.dart'; +import '../../shared/state/general_provider.dart'; +import '../../shared/components/url_selector.dart'; + +class StoreEditorPopup extends StatefulWidget { + const StoreEditorPopup({super.key}); + + static const String route = '/storeEditor'; + + @override + State createState() => _StoreEditorPopupState(); +} + +class _StoreEditorPopupState extends State { + final formKey = GlobalKey(); + + late final String? existingStoreName; + late final Future>? existingMetadata; + + late final Future> existingStores; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + existingStoreName = ModalRoute.of(context)!.settings.arguments as String?; + existingMetadata = existingStoreName == null + ? null + : FMTCStore(existingStoreName!).metadata.read; + + existingStores = + FMTCRoot.stats.storesAvailable.then((l) => l.map((s) => s.storeName)); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text( + existingStoreName == null + ? 'Create New Store' + : "Edit '$existingStoreName'", + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Scrollbar( + child: SingleChildScrollView( + child: Form( + key: formKey, + child: Column( + children: [ + const SizedBox(height: 12), + FutureBuilder( + initialData: const [], + future: existingStores, + builder: (context, snapshot) => TextFormField( + decoration: const InputDecoration( + labelText: 'Store Name', + prefixIcon: Icon(Icons.text_fields), + filled: true, + ), + validator: (input) => input == null || input.isEmpty + ? 'Required' + : snapshot.data!.contains(input) && + input != existingStoreName + ? 'Store already exists' + : input == '(default)' || input == '(custom)' + ? 'Name reserved (in example app)' + : null, + //onSaved: (input) => _newValues['storeName'] = input!, + maxLength: 64, + autovalidateMode: AutovalidateMode.onUserInteraction, + initialValue: existingStoreName, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + ), + ), + const SizedBox(height: 6), + FutureBuilder( + future: existingMetadata, + builder: (context, snapshot) { + if (snapshot.data == null && + existingStoreName != null) { + return const CircularProgressIndicator.adaptive(); + } + + return URLSelector( + onSelected: (_) {}, + initialValue: snapshot + .data?[StoreMetadataKeys.urlTemplate.key] ?? + context.select( + (provider) => provider.urlTemplate, + ), + helperText: + 'In the example app, stores only contain tiles from one source', + ); + }, + ), + ], + ), + ), + ), + ), + ), + /*body: Consumer( + builder: (context, provider, _) => Padding( + padding: const EdgeInsets.all(12), + child: FutureBuilder?>( + future:existingStoreName == null + ? Future.sync(() => {}) + : FMTCStore(existingStoreName!).metadata.read, + builder: (context, metadata) { + if (!metadata.hasData || metadata.data == null) { + return const LoadingIndicator('Retrieving Settings'); + } + return Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + children: [ + TextFormField( + decoration: const InputDecoration( + labelText: 'Store Name', + prefixIcon: Icon(Icons.text_fields), + isDense: true, + ), + onChanged: (input) async { + _storeNameIsDuplicate = + (await FMTCRoot.stats.storesAvailable) + .contains(FMTCStore(input)); + setState(() {}); + }, + validator: (input) => input == null || input.isEmpty + ? 'Required' + : _storeNameIsDuplicate + ? 'Store already exists' + : null, + onSaved: (input) => _newValues['storeName'] = input!, + autovalidateMode: AutovalidateMode.onUserInteraction, + textCapitalization: TextCapitalization.words, + initialValue: widget.existingStoreName, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 5), + TextFormField( + decoration: const InputDecoration( + labelText: 'Map Source URL', + helperText: + "Use '{x}', '{y}', '{z}' as placeholders. Include protocol. Omit subdomain.", + prefixIcon: Icon(Icons.link), + isDense: true, + ), + onChanged: (i) async { + final uri = Uri.tryParse( + NetworkTileProvider().getTileUrl( + const TileCoordinates(0, 0, 0), + TileLayer(urlTemplate: i), + ), + ); + + if (uri == null) { + setState( + () => _httpRequestFailed = 'Invalid URL', + ); + return; + } + + _httpRequestFailed = await http.get(uri).then( + (res) => res.statusCode == 200 + ? null + : 'HTTP Request Failed', + onError: (_) => 'HTTP Request Failed', + ); + setState(() {}); + }, + validator: (i) { + final String input = i ?? ''; + + if (!validators.isURL( + input, + protocols: ['http', 'https'], + requireProtocol: true, + )) { + return 'Invalid URL'; + } + if (!input.contains('{x}') || + !input.contains('{y}') || + !input.contains('{z}')) { + return 'Missing placeholder(s)'; + } + + return _httpRequestFailed; + }, + onSaved: (input) => _newValues['sourceURL'] = input!, + autovalidateMode: AutovalidateMode.onUserInteraction, + keyboardType: TextInputType.url, + initialValue: metadata.data!.isEmpty + ? 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + : metadata.data!['sourceURL'], + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 5), + TextFormField( + decoration: const InputDecoration( + labelText: 'Valid Cache Duration', + helperText: 'Use 0 to disable expiry', + suffixText: 'days', + prefixIcon: Icon(Icons.timelapse), + isDense: true, + ), + validator: (input) { + if (input == null || + input.isEmpty || + int.parse(input) < 0) { + return 'Must be 0 or more'; + } + return null; + }, + onSaved: (input) => + _newValues['validDuration'] = input!, + autovalidateMode: AutovalidateMode.onUserInteraction, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + initialValue: metadata.data!.isEmpty + ? '14' + : metadata.data!['validDuration'], + textInputAction: TextInputAction.done, + ), + const SizedBox(height: 5), + TextFormField( + decoration: const InputDecoration( + labelText: 'Maximum Length', + helperText: 'Use 0 to disable limit', + suffixText: 'tiles', + prefixIcon: Icon(Icons.disc_full), + isDense: true, + ), + validator: (input) { + if (input == null || + input.isEmpty || + int.parse(input) < 0) { + return 'Must be 0 or more'; + } + return null; + }, + onSaved: (input) => _newValues['maxLength'] = input!, + autovalidateMode: AutovalidateMode.onUserInteraction, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + initialValue: metadata.data!.isEmpty + ? '100000' + : metadata.data!['maxLength'], + textInputAction: TextInputAction.done, + ), + Row( + children: [ + const Text('Cache Behaviour:'), + const SizedBox(width: 10), + Expanded( + child: DropdownButton( + value: _useNewCacheModeValue + ? _cacheModeValue! + : metadata.data!.isEmpty + ? 'cacheFirst' + : metadata.data!['behaviour'], + onChanged: (newVal) => setState( + () { + _cacheModeValue = newVal ?? 'cacheFirst'; + _useNewCacheModeValue = true; + }, + ), + items: [ + 'cacheFirst', + 'onlineFirst', + 'cacheOnly', + ] + .map>( + (v) => DropdownMenuItem( + value: v, + child: Text(v), + ), + ) + .toList(), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ), + ),*/ + ); +} diff --git a/example/lib/src/shared/components/url_selector.dart b/example/lib/src/shared/components/url_selector.dart new file mode 100644 index 00000000..3d38001e --- /dev/null +++ b/example/lib/src/shared/components/url_selector.dart @@ -0,0 +1,254 @@ +import 'dart:async'; + +import 'package:async/async.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +import '../misc/shared_preferences.dart'; +import '../misc/store_metadata_keys.dart'; + +class URLSelector extends StatefulWidget { + const URLSelector({ + super.key, + this.initialValue, + this.onSelected, + this.helperText, + }); + + final String? initialValue; + final void Function(String)? onSelected; + final String? helperText; + + @override + State createState() => _URLSelectorState(); +} + +class _URLSelectorState extends State { + static const _sharedPrefsNonStoreUrlsKey = 'customNonStoreUrls'; + static const _defaultUrlTemplate = + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + + late final urlTextController = TextEditingControllerWithMatcherStylizer( + TileProvider.templatePlaceholderElement, + const TextStyle(fontStyle: FontStyle.italic), + initialValue: widget.initialValue ?? _defaultUrlTemplate, + ); + + final selectableEntriesManualRefreshStream = StreamController(); + + late final inUseUrlsStream = (StreamGroup() + ..add(FMTCRoot.stats.watchStores(triggerImmediately: true)) + ..add(selectableEntriesManualRefreshStream.stream)) + .stream + .asyncMap(_constructTemplatesToStoresStream); + + Map> enableButtonEvaluatorMap = {}; + final enableAddUrlButton = ValueNotifier(false); + + @override + void initState() { + super.initState(); + urlTextController.addListener(_urlTextControllerListener); + } + + @override + void dispose() { + urlTextController.removeListener(_urlTextControllerListener); + selectableEntriesManualRefreshStream.close(); + super.dispose(); + } + + void _urlTextControllerListener() { + enableAddUrlButton.value = + !enableButtonEvaluatorMap.containsKey(urlTextController.text); + } + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => SizedBox( + width: constraints.maxWidth, + child: StreamBuilder>>( + initialData: const { + _defaultUrlTemplate: ['(default)'], + }, + stream: inUseUrlsStream, + 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 + ..value = TextEditingValue.empty + ..value = oldValue; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DropdownMenu( + controller: urlTextController, + width: constraints.maxWidth, + requestFocusOnTap: true, + leadingIcon: const Icon(Icons.link), + label: const Text('URL Template'), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + helperMaxLines: 2, + ), + initialSelection: + widget.initialValue ?? _defaultUrlTemplate, + // Bug in `DropdownMenu` means this cannot be `true` + // enableFilter: true, + dropdownMenuEntries: _constructMenuEntries(snapshot), + onSelected: _onSelected, + helperText: 'Use standard placeholders & include protocol' + '${widget.helperText != null ? '\n${widget.helperText}' : ''}', + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6, left: 8), + child: ValueListenableBuilder( + valueListenable: enableAddUrlButton, + builder: (context, enableAddUrlButton, _) => + IconButton.filledTonal( + onPressed: + enableAddUrlButton ? () => _onSelected(null) : null, + icon: const Icon(Icons.add_link), + ), + ), + ), + ], + ); + }, + ), + ), + ); + + void _onSelected(String? v) { + if (v == null) { + sharedPrefs.setStringList( + _sharedPrefsNonStoreUrlsKey, + (sharedPrefs.getStringList(_sharedPrefsNonStoreUrlsKey) ?? []) + ..add(urlTextController.text), + ); + + selectableEntriesManualRefreshStream.add(null); + } + + widget.onSelected!(v ?? urlTextController.text); + } + + 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(_sharedPrefsNonStoreUrlsKey) ?? []) + .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; + } + + List> _constructMenuEntries( + AsyncSnapshot>> snapshot, + ) => + snapshot.data!.entries + .map>( + (e) => DropdownMenuEntry( + value: e.key, + label: e.key, + labelWidget: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(Uri.tryParse(e.key)?.host ?? e.key), + Text( + 'Used by: ${e.value.join(', ')}', + style: const TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + trailingIcon: e.value.contains('(custom)') + ? IconButton( + onPressed: () { + sharedPrefs.setStringList( + _sharedPrefsNonStoreUrlsKey, + (sharedPrefs + .getStringList(_sharedPrefsNonStoreUrlsKey) ?? + []) + ..remove(e.key), + ); + + selectableEntriesManualRefreshStream.add(null); + }, + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove URL from non-store list', + ) + : null, + ), + ) + .toList() + ..add( + const DropdownMenuEntry( + value: null, + label: + 'To use another URL (without using it in a store),\nenter it, ' + 'then hit enter/done/add', + leadingIcon: Icon(Icons.add_link), + enabled: false, + ), + ); +} + +// Inspired by https://stackoverflow.com/a/59773962/11846040 +class TextEditingControllerWithMatcherStylizer extends TextEditingController { + TextEditingControllerWithMatcherStylizer( + this.pattern, + this.matchedStyle, { + String? initialValue, + }) : super(text: initialValue); + + final Pattern pattern; + final TextStyle matchedStyle; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final List children = []; + + text.splitMapJoin( + pattern, + onMatch: (match) { + children.add(TextSpan(text: match[0], style: matchedStyle)); + return ''; + }, + onNonMatch: (text) { + children.add(TextSpan(text: text, style: style)); + return ''; + }, + ); + + return TextSpan(style: style, children: children); + } +} diff --git a/example/lib/src/shared/misc/store_metadata_keys.dart b/example/lib/src/shared/misc/store_metadata_keys.dart new file mode 100644 index 00000000..ad2b2595 --- /dev/null +++ b/example/lib/src/shared/misc/store_metadata_keys.dart @@ -0,0 +1,6 @@ +enum StoreMetadataKeys { + urlTemplate('sourceURL'); + + const StoreMetadataKeys(this.key); + final String key; +} diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart index fc87bb21..9af69539 100644 --- a/example/lib/src/shared/state/general_provider.dart +++ b/example/lib/src/shared/state/general_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; class GeneralProvider extends ChangeNotifier { Set _currentStores = {}; @@ -10,6 +11,13 @@ class GeneralProvider extends ChangeNotifier { notifyListeners(); } + String _urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + String get urlTemplate => _urlTemplate; + set urlTemplate(String newUrlTemplate) { + _urlTemplate = newUrlTemplate; + notifyListeners(); + } + void removeStore(String store) { _currentStores.remove(store); notifyListeners(); @@ -20,6 +28,36 @@ class GeneralProvider extends ChangeNotifier { notifyListeners(); } + CacheBehavior get behaviour => + switch ((_behaviourPrimary, _behaviourUpdateFromNetwork)) { + (null, _) => CacheBehavior.cacheOnly, + (false, false) => CacheBehavior.cacheFirstNoUpdate, + (false, true) => CacheBehavior.cacheFirst, + (true, false) => CacheBehavior.onlineFirstNoUpdate, + (true, true) => CacheBehavior.onlineFirst, + }; + + bool? _behaviourPrimary = false; + bool? get behaviourPrimary => _behaviourPrimary; + set behaviourPrimary(bool? newBehaviourPrimary) { + _behaviourPrimary = newBehaviourPrimary; + notifyListeners(); + } + + bool _behaviourUpdateFromNetwork = true; + bool get behaviourUpdateFromNetwork => _behaviourUpdateFromNetwork; + set behaviourUpdateFromNetwork(bool newBehaviourUpdateFromNetwork) { + _behaviourUpdateFromNetwork = newBehaviourUpdateFromNetwork; + notifyListeners(); + } + + bool _displayDebugOverlay = true; + bool get displayDebugOverlay => _displayDebugOverlay; + set displayDebugOverlay(bool newDisplayDebugOverlay) { + _displayDebugOverlay = newDisplayDebugOverlay; + notifyListeners(); + } + bool? _storesSelectionMode = true; bool? get storesSelectionMode => _storesSelectionMode; set storesSelectionMode(bool? newSelectionMode) { diff --git a/example/pubspec.yaml b/example/pubspec.yaml index fe974ecd..2b253657 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -10,6 +10,7 @@ environment: flutter: ">=3.19.0" dependencies: + async: ^2.11.0 auto_size_text: ^3.0.0 badges: ^3.1.2 better_open_file: ^3.6.5 @@ -38,6 +39,8 @@ dependencies: dependency_overrides: flutter_map_tile_caching: path: ../ + flutter_map: + path: D:/flutter_map flutter: uses-material-design: true diff --git a/lib/src/providers/tile_provider_settings.dart b/lib/src/providers/tile_provider_settings.dart index 5220cf96..4f3f0962 100644 --- a/lib/src/providers/tile_provider_settings.dart +++ b/lib/src/providers/tile_provider_settings.dart @@ -6,6 +6,11 @@ part of '../../flutter_map_tile_caching.dart'; /// Callback type that takes an [FMTCBrowsingError] exception typedef FMTCBrowsingErrorHandler = void Function(FMTCBrowsingError exception); +/// Alias of [CacheBehavior] +/// +/// ... with the correct spelling :D +typedef CacheBehaviour = CacheBehavior; + /// Behaviours dictating how and when browse caching should occur /// /// | `CacheBehavior` | Preferred fetch method | Fallback fetch method | Update cache when network used | diff --git a/lib/src/regions/circle.dart b/lib/src/regions/circle.dart index e3ea0b0c..7f2caa56 100644 --- a/lib/src/regions/circle.dart +++ b/lib/src/regions/circle.dart @@ -47,6 +47,8 @@ class CircleRegion extends BaseRegion { final radius = this.radius * 1000; + if (radius == 0) return; // Otherwise, 360 points of the same one coord + for (int angle = -180; angle <= 180; angle++) { yield dist.offset(center, radius, angle); } From 0e89c6e333a530dfe68d136afc1951aefcc375c7 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 4 Jul 2024 15:30:16 +0100 Subject: [PATCH 08/97] Added improved multiple stores support to `FMTCTileProvider` Added `StoreReadWriteBehavior` for improved customizability --- .../impls/objectbox/backend/backend.dart | 1 + .../impls/objectbox/backend/internal.dart | 109 +++++----- .../backend/internal_workers/shared.dart | 48 +++-- .../internal_workers/standard/worker.dart | 29 ++- .../backend/interfaces/backend/internal.dart | 36 ++-- .../bulk_download/rate_limited_stream.dart | 2 + lib/src/providers/browsing_errors.dart | 5 +- lib/src/providers/image_provider.dart | 204 ++++++++++-------- lib/src/providers/tile_provider.dart | 87 +++++--- lib/src/providers/tile_provider_settings.dart | 89 ++++---- lib/src/store/store.dart | 11 +- 11 files changed, 366 insertions(+), 255 deletions(-) diff --git a/lib/src/backend/impls/objectbox/backend/backend.dart b/lib/src/backend/impls/objectbox/backend/backend.dart index 2d02d452..957c925f 100644 --- a/lib/src/backend/impls/objectbox/backend/backend.dart +++ b/lib/src/backend/impls/objectbox/backend/backend.dart @@ -15,6 +15,7 @@ import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../../../../../flutter_map_tile_caching.dart'; +import '../../../../misc/int_extremes.dart'; import '../../../export_internal.dart'; import '../models/generated/objectbox.g.dart'; import '../models/src/recovery.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/internal.dart b/lib/src/backend/impls/objectbox/backend/internal.dart index c6775272..d2a097bc 100644 --- a/lib/src/backend/impls/objectbox/backend/internal.dart +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -27,7 +27,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { SendPort? _sendPort; final _workerResOneShot = ?>>{}; final _workerResStreamed = ?>>{}; - int _workerId = 0; + int _workerId = smallestInt; late Completer _workerComplete; late StreamSubscription _workerHandler; @@ -88,27 +88,23 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { controller.sink; // Will be inserted into by direct handler _sendPort!.send((id: id, type: type, args: args)); // Send cmd - try { - // Not using yield* as it doesn't allow for correct error handling - // (because result must be 'evaluated' here, instead of a direct - // passthrough) - await for (final evt in controller.stream) { - // Listen to responses - yield evt; - } - } catch (err, stackTrace) { - yield Error.throwWithStackTrace( + // Efficienctly forward resulting stream, but add extra debug info to any + // errors + // TODO: verify + yield* controller.stream.handleError( + (err, stackTrace) => Error.throwWithStackTrace( err, StackTrace.fromString( - '$stackTrace\n#+ [FMTC] Unable to ' - 'attach final `StackTrace` when streaming results\n\n#+ [FMTC] (Debug Info) $type: $args\n', + '$stackTrace\n#+ [FMTC Debug Info] ' + ' Unable to attach final `StackTrace` when streaming results\n' + '\n#+ [FMTC Debug Info] ' + '$type: $args\n', ), - ); - } finally { - // Goto `onCancel` once output listening cancelled - await controller.close(); - } + ), + ); + + // Goto `onCancel` once output listening cancelled + await controller.close(); } // Lifecycle implementations @@ -207,27 +203,33 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ); // Spawn worker isolate - await Isolate.spawn( - _worker, - ( - sendPort: receivePort.sendPort, - rootDirectory: this.rootDirectory, - maxDatabaseSize: maxDatabaseSize, - macosApplicationGroup: macosApplicationGroup, - rootIsolateToken: rootIsolateToken, - ), - onExit: receivePort.sendPort, - debugName: '[FMTC] ObjectBox Backend Worker', - ); + try { + await Isolate.spawn( + _worker, + ( + sendPort: receivePort.sendPort, + rootDirectory: this.rootDirectory, + maxDatabaseSize: maxDatabaseSize, + macosApplicationGroup: macosApplicationGroup, + rootIsolateToken: rootIsolateToken, + ), + onExit: receivePort.sendPort, + debugName: '[FMTC] ObjectBox Backend Worker', + ); + } catch (e) { + receivePort.close(); + _sendPort = null; + rethrow; + } // Check whether initialisation was successful after initial response if (await workerInitialRes case (:final err, :final stackTrace)) { Error.throwWithStackTrace(err, stackTrace); - } else { - FMTCBackendAccess.internal = this; - FMTCBackendAccessThreadSafe.internal = - _ObjectBoxBackendThreadSafeImpl._(rootDirectory: this.rootDirectory); } + + FMTCBackendAccess.internal = this; + FMTCBackendAccessThreadSafe.internal = + _ObjectBoxBackendThreadSafeImpl._(rootDirectory: this.rootDirectory); } Future uninitialise({ @@ -383,18 +385,12 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ))!['exists']; @override - Future readTile({ - required String url, - List? storeNames, - }) async => - (await _sendCmdOneShot( - type: _CmdType.readTile, - args: {'url': url, 'storeNames': storeNames}, - ))!['tile']; - - @override - Future<({BackendTile? tile, List storeNames})> - readTileWithStoreNames({ + Future< + ({ + BackendTile? tile, + List intersectedStoreNames, + List allStoreNames, + })> readTile({ required String url, List? storeNames, }) async { @@ -404,7 +400,8 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ))!; return ( tile: res['tile'] as BackendTile?, - storeNames: res['stores'] as List, + intersectedStoreNames: res['intersectedStoreNames'] as List, + allStoreNames: res['allStoreNames'] as List, ); } @@ -418,15 +415,21 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ))!['tile']; @override - Future writeTile({ + Future> writeTile({ required String url, required Uint8List bytes, required List storeNames, - }) => - _sendCmdOneShot( + required List? writeAllNotIn, + }) async => + (await _sendCmdOneShot( type: _CmdType.writeTile, - args: {'storeNames': storeNames, 'url': url, 'bytes': bytes}, - ); + args: { + 'storeNames': storeNames, + 'writeAllNotIn': writeAllNotIn, + 'url': url, + 'bytes': bytes, + }, + ))!['newStores']; @override Future deleteTile({ diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart index 275f8c36..68a4bd9d 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart @@ -3,22 +3,34 @@ part of '../backend.dart'; -void _sharedWriteSingleTile({ +List _sharedWriteSingleTile({ required Store root, required List storeNames, required String url, required Uint8List bytes, + List? writeAllNotIn, }) { final tiles = root.box(); final storesBox = root.box(); final rootBox = root.box(); + if (writeAllNotIn != null) { + storeNames.addAll( + storesBox + .getAll() + .map((e) => e.name) + .where((e) => !writeAllNotIn.contains(e) && !storeNames.contains(e)), + ); + } + final tilesQuery = tiles.query(ObjectBoxTile_.url.equals(url)).build(); final storeQuery = storesBox.query(ObjectBoxStore_.name.oneOf(storeNames)).build(); final storesToUpdate = {}; + final createdIn = {}; + root.runInTransaction( TxMode.write, () { @@ -49,13 +61,16 @@ void _sharedWriteSingleTile({ storesToUpdate.addEntries( stores.whereNot((s) => didContainAlready.contains(s.name)).map( - (s) => MapEntry( - s.name, - s - ..length += 1 - ..size += bytes.lengthInBytes, - ), - ), + (s) { + createdIn.add(s.name); + return MapEntry( + s.name, + s + ..length += 1 + ..size += bytes.lengthInBytes, + ); + }, + ), ); } else { rootBox.put( @@ -67,12 +82,15 @@ void _sharedWriteSingleTile({ storesToUpdate.addEntries( stores.map( - (s) => MapEntry( - s.name, - s - ..length += 1 - ..size += bytes.lengthInBytes, - ), + (s) { + createdIn.add(s.name); + return MapEntry( + s.name, + s + ..length += 1 + ..size += bytes.lengthInBytes, + ); + }, ), ); } @@ -90,4 +108,6 @@ void _sharedWriteSingleTile({ tilesQuery.close(); storeQuery.close(); + + return createdIn.toList(growable: false); } diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index 10e6acff..c9cef236 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -424,14 +424,15 @@ Future _worker( final storeNames = cmd.args['storeNames'] as List?; final stores = root.box(); + final specifiedStores = storeNames?.isNotEmpty ?? false; final queryPart = stores.query(ObjectBoxTile_.url.equals(url)); - final query = storeNames == null + final query = !specifiedStores ? queryPart.build() : (queryPart ..linkMany( ObjectBoxTile_.stores, - ObjectBoxStore_.name.oneOf(storeNames), + ObjectBoxStore_.name.oneOf(storeNames!), )) .build(); @@ -439,23 +440,31 @@ Future _worker( query.close(); if (tile == null) { - sendRes(id: cmd.id, data: {'tile': null, 'stores': []}); + sendRes( + id: cmd.id, + data: { + 'tile': null, + 'allStoreNames': const [], + 'intersectedStoreNames': const [], + }, + ); } else { final tileStores = tile.stores.map((s) => s.name); + final listTileStores = tileStores.toList(growable: false); sendRes( id: cmd.id, data: { 'tile': tile, - 'stores': storeNames == null - ? tileStores.toList(growable: false) + 'allStoreNames': listTileStores, + 'intersectedStoreNames': !specifiedStores + ? listTileStores : SplayTreeSet.from(tileStores) - .intersection(SplayTreeSet.from(storeNames)) + .intersection(SplayTreeSet.from(storeNames!)) .toList(growable: false), }, ); } - case _CmdType.readLatestTile: final storeName = cmd.args['storeName']! as String; @@ -474,17 +483,19 @@ Future _worker( query.close(); case _CmdType.writeTile: final storeNames = cmd.args['storeNames']! as List; + final writeAllNotIn = cmd.args['writeAllNotIn'] as List?; final url = cmd.args['url']! as String; final bytes = cmd.args['bytes']! as Uint8List; - _sharedWriteSingleTile( + final result = _sharedWriteSingleTile( root: root, storeNames: storeNames, + writeAllNotIn: writeAllNotIn, url: url, bytes: bytes, ); - sendRes(id: cmd.id); + sendRes(id: cmd.id, data: {'newStores': result}); case _CmdType.deleteTile: final storeName = cmd.args['storeName']! as String; final url = cmd.args['url']! as String; diff --git a/lib/src/backend/interfaces/backend/internal.dart b/lib/src/backend/interfaces/backend/internal.dart index c0e3a067..2ae4ecce 100644 --- a/lib/src/backend/interfaces/backend/internal.dart +++ b/lib/src/backend/interfaces/backend/internal.dart @@ -162,19 +162,24 @@ abstract interface class FMTCBackendInternal List? storeNames, }); - /// Retrieve a raw tile by the specified URL - /// - /// If [storeNames] is specified, the tile will be limited to the specified - /// stores - if it exists in another store, it will not be returned. - Future readTile({ - required String url, - List? storeNames, - }); - - /// Same as [readTile], but also returns the list of store names which this - /// tile belongs to and were present in [storeNames] (if specified) - Future<({BackendTile? tile, List storeNames})> - readTileWithStoreNames({ + /// Retrieve a raw `tile` from any of the specified [storeNames] (or all store + /// names if `null` or empty) by the specified URL + /// + /// Returns the list of store names the tile belongs to - `allStoreNames` - + /// and were present in [storeNames] if specified - `intersectedStoreNames`. + /// + /// If [storeNames] is `null` or empty, tiles may be retrieved from any store + /// (which may be slower depending on the size of the root, as queries may + /// be unconstrained). + /// + /// `intersectedStoreNames` & `allStoreNames` will be empty if `tile` is + /// `null`. + Future< + ({ + BackendTile? tile, + List intersectedStoreNames, + List allStoreNames, + })> readTile({ required String url, List? storeNames, }); @@ -189,10 +194,13 @@ abstract interface class FMTCBackendInternal /// Create or update a tile (given a [url] and its [bytes]) in the specified /// store - Future writeTile({ + /// + /// Returns the stores that the tile was created in (not already existing). + Future> writeTile({ required String url, required Uint8List bytes, required List storeNames, + required List? writeAllNotIn, }); /// Remove the tile from the specified store, deleting it if was orphaned diff --git a/lib/src/bulk_download/rate_limited_stream.dart b/lib/src/bulk_download/rate_limited_stream.dart index 02665f19..d7f7cb36 100644 --- a/lib/src/bulk_download/rate_limited_stream.dart +++ b/lib/src/bulk_download/rate_limited_stream.dart @@ -4,6 +4,8 @@ import 'dart:async'; /// Rate limiting extension, see [rateLimit] for more information +/// +// TODO: check https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/throttle.html extension RateLimitedStream on Stream { /// Transforms a series of events to an output stream where a delay of at least /// [minimumSpacing] is inserted between every event diff --git a/lib/src/providers/browsing_errors.dart b/lib/src/providers/browsing_errors.dart index cb0af4f0..aa1b247f 100644 --- a/lib/src/providers/browsing_errors.dart +++ b/lib/src/providers/browsing_errors.dart @@ -76,8 +76,9 @@ class FMTCBrowsingError implements Exception { /// request /// /// Will be available if [type] is - /// [FMTCBrowsingErrorType.noConnectionDuringFetch] or - /// [FMTCBrowsingErrorType.unknownFetchException]. + /// [FMTCBrowsingErrorType.noConnectionDuringFetch], + /// [FMTCBrowsingErrorType.unknownFetchException], or + /// [FMTCBrowsingErrorType.invalidImageData]. final Object? originalError; @override diff --git a/lib/src/providers/image_provider.dart b/lib/src/providers/image_provider.dart index 54e82be7..e32da650 100644 --- a/lib/src/providers/image_provider.dart +++ b/lib/src/providers/image_provider.dart @@ -5,6 +5,9 @@ part of '../../flutter_map_tile_caching.dart'; /// A specialised [ImageProvider] that uses FMTC internals to enable browse /// caching +/// +/// TODO: Improve hits and misses +/// TODO: Debug tile output class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { /// Create a specialised [ImageProvider] that uses FMTC internals to enable /// browse caching @@ -79,7 +82,12 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { ); } catch (err, stackTrace) { close(); - if (err is FMTCBrowsingError) provider.settings.errorHandler?.call(err); + if (err is FMTCBrowsingError) { + final handlerResult = provider.settings.errorHandler?.call(err); + if (handlerResult != null) { + return instantiateImageCodecFromBuffer(handlerResult); + } + } Error.throwWithStackTrace(err, stackTrace); } @@ -130,20 +138,11 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { void registerMiss() { if (provider.settings.recordHitsAndMisses) { - FMTCBackendAccess.internal - .registerHitOrMiss(storeNames: provider.storeNames, hit: false); - } - } - - Future attemptFinishViaAltStore(String matcherUrl) async { - if (provider.settings.fallbackToAlternativeStore) { - final existingTileAltStore = - await FMTCBackendAccess.internal.readTile(url: matcherUrl); - if (existingTileAltStore == null) return null; - registerMiss(); - return existingTileAltStore.bytes; + FMTCBackendAccess.internal.registerHitOrMiss( + storeNames: provider._getSpecifiedStoresOrNull(), // TODO: Verify + hit: false, + ); } - return null; } final networkUrl = provider.getTileUrl(coords, options); @@ -152,45 +151,57 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { obscuredQueryParams: provider.settings.obscuredQueryParams, ); - final (tile: existingTile, storeNames: existingStores) = - await FMTCBackendAccess.internal.readTileWithStoreNames( + final ( + tile: existingTile, + intersectedStoreNames: intersectedExistingStores, + allStoreNames: allExistingStores, + ) = await FMTCBackendAccess.internal.readTile( url: matcherUrl, - storeNames: provider.storeNames, + storeNames: provider._getSpecifiedStoresOrNull(), ); - final needsCreating = existingTile == null; - final needsUpdating = !needsCreating && - (provider.settings.behavior == CacheBehavior.onlineFirst || - // Tile will not be written if *NoUpdate, regardless of this value - provider.settings.behavior == CacheBehavior.onlineFirstNoUpdate || - (provider.settings.cachedValidDuration != Duration.zero && - DateTime.timestamp().millisecondsSinceEpoch - - existingTile.lastModified.millisecondsSinceEpoch > - provider.settings.cachedValidDuration.inMilliseconds)); + const useUnspecifiedAsLastResort = true; + + final tileExistsInUnspecifiedStoresOnly = existingTile != null && + useUnspecifiedAsLastResort && + provider.storeNames.keys + .toSet() + .union(allExistingStores.toSet()) + .isEmpty; // Prepare a list of image bytes and prefill if there's already a cached // tile available Uint8List? bytes; - if (!needsCreating) bytes = existingTile.bytes; + if (existingTile != null) bytes = existingTile.bytes; // If there is a cached tile that's in date available, use it - if (!needsCreating && !needsUpdating) { - registerHit(existingStores); + final needsUpdating = existingTile != null && + (provider.settings.behavior == CacheBehavior.onlineFirst || + (provider.settings.cachedValidDuration != Duration.zero && + DateTime.timestamp().millisecondsSinceEpoch - + existingTile.lastModified.millisecondsSinceEpoch > + provider.settings.cachedValidDuration.inMilliseconds)); + if (existingTile != null && + !needsUpdating && + !tileExistsInUnspecifiedStoresOnly) { + registerHit(intersectedExistingStores); return bytes!; } // If a tile is not available and cache only mode is in use, just fail // before attempting a network call - if (provider.settings.behavior == CacheBehavior.cacheOnly && - needsCreating) { - final altBytes = await attemptFinishViaAltStore(matcherUrl); - if (altBytes != null) return altBytes; - - throw FMTCBrowsingError( - type: FMTCBrowsingErrorType.missingInCacheOnlyMode, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - ); + if (provider.settings.behavior == CacheBehavior.cacheOnly) { + if (tileExistsInUnspecifiedStoresOnly) { + registerMiss(); + return bytes!; + } + if (existingTile == null) { + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.missingInCacheOnlyMode, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + ); + } } // Setup a network request for the tile & handle network exceptions @@ -200,14 +211,11 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { try { response = await provider.httpClient.send(request); } catch (e) { - if (!needsCreating) { + if (existingTile != null) { registerMiss(); return bytes!; } - final altBytes = await attemptFinishViaAltStore(matcherUrl); - if (altBytes != null) return altBytes; - throw FMTCBrowsingError( type: e is SocketException ? FMTCBrowsingErrorType.noConnectionDuringFetch @@ -221,14 +229,11 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { // Check whether the network response is not 200 OK if (response.statusCode != 200) { - if (!needsCreating) { + if (existingTile != null) { registerMiss(); return bytes!; } - final altBytes = await attemptFinishViaAltStore(matcherUrl); - if (altBytes != null) return altBytes; - throw FMTCBrowsingError( type: FMTCBrowsingErrorType.negativeFetchResponse, networkUrl: networkUrl, @@ -253,63 +258,88 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { // Perform a secondary check to ensure that the bytes recieved actually // encode a valid image - late final bool isValidImageData; - try { - isValidImageData = (await (await instantiateImageCodec( - responseBytes, - targetWidth: 8, - targetHeight: 8, - )) - .getNextFrame()) - .image - .width > - 0; - } catch (e) { - isValidImageData = false; - } - if (!isValidImageData) { - if (!needsCreating) { - registerMiss(); - return bytes!; + if (requireValidImage) { + late final Object? isValidImageData; + + try { + isValidImageData = (await (await instantiateImageCodec( + responseBytes, + targetWidth: 8, + targetHeight: 8, + )) + .getNextFrame()) + .image + .width > + 0 + ? null + : Exception('Image was decodable, but had a width of 0'); + } catch (e) { + isValidImageData = e; } - final altBytes = await attemptFinishViaAltStore(matcherUrl); - if (altBytes != null) return altBytes; + if (isValidImageData != null) { + if (existingTile != null) { + registerMiss(); + return bytes!; + } - if (requireValidImage) { throw FMTCBrowsingError( type: FMTCBrowsingErrorType.invalidImageData, networkUrl: networkUrl, matcherUrl: matcherUrl, request: request, response: response, + originalError: isValidImageData, ); - } else { - registerMiss(); - return responseBytes; } } - // Cache the tile retrieved from the network response, if behaviour allows - if (provider.settings.behavior != CacheBehavior.cacheFirstNoUpdate && - provider.settings.behavior != CacheBehavior.onlineFirstNoUpdate && - (provider.storeNames?.isNotEmpty ?? false)) { + // Find the stores that need to have this tile written to, depending on + // their read/write settings + // At this point, we've downloaded the tile anyway, so we might as well + // write the stores that allow it, even if the existing tile hasn't expired + final writeTileToSpecified = provider.storeNames.entries + .where( + (e) => switch (e.value) { + StoreReadWriteBehavior.read => false, + StoreReadWriteBehavior.readUpdate => + intersectedExistingStores.contains(e.key), + StoreReadWriteBehavior.readUpdateCreate => true, + }, + ) + .map((e) => e.key); + final writeTileToIntermediate = + provider.otherStoresBehavior == StoreReadWriteBehavior.readUpdate && + existingTile != null + ? writeTileToSpecified.followedBy( + intersectedExistingStores + .whereNot((e) => provider.storeNames.containsKey(e)), + ) + : writeTileToSpecified; + + // Cache tile to necessary stores + if (writeTileToIntermediate.isNotEmpty || + provider.otherStoresBehavior == + StoreReadWriteBehavior.readUpdateCreate) { unawaited( - FMTCBackendAccess.internal.writeTile( - storeNames: provider.storeNames!, + FMTCBackendAccess.internal + .writeTile( + storeNames: writeTileToIntermediate.toSet().toList(growable: false), + writeAllNotIn: provider.otherStoresBehavior == + StoreReadWriteBehavior.readUpdateCreate + ? provider.storeNames.keys.toList(growable: false) + : null, url: matcherUrl, bytes: responseBytes, - ), + ) + .then((createdIn) { + // Clear out old tiles if the maximum store length has been exceeded + // We only need to even attempt this if the number of tiles has changed + if (createdIn.isEmpty) return; + FMTCBackendAccess.internal + .removeOldestTilesAboveLimit(storeNames: createdIn); + }), ); - - // Clear out old tiles if the maximum store length has been exceeded - if (needsCreating) { - unawaited( - FMTCBackendAccess.internal.removeOldestTilesAboveLimit( - storeNames: provider.storeNames!, - ), - ); - } } registerMiss(); diff --git a/lib/src/providers/tile_provider.dart b/lib/src/providers/tile_provider.dart index f53eb12a..2b0cb3eb 100644 --- a/lib/src/providers/tile_provider.dart +++ b/lib/src/providers/tile_provider.dart @@ -6,64 +6,74 @@ part of '../../flutter_map_tile_caching.dart'; /// Specialised [TileProvider] that uses a specialised [ImageProvider] to connect /// to FMTC internals and enable advanced caching/retrieval logic /// +/// To use a single store, use [FMTCStore.getTileProvider]. +/// +/// To use multiple stores, use the [FMTCTileProvider.multipleStores] +/// constructor. See documentation on [storeNames] and [otherStoresBehavior] +/// for information on usage. +/// +/// To use all stores, use the [FMTCTileProvider.allStores] constructor. See +/// documentation on [otherStoresBehavior] for information on usage. +/// /// An "FMTC" identifying mark is injected into the "User-Agent" header generated /// by flutter_map, except if specified in the constructor. For technical /// details, see [_CustomUserAgentCompatMap]. +/// +/// Can be constructed alternatively with [FMTCStore.getTileProvider] to +/// support a single store. class FMTCTileProvider extends TileProvider { - /// Create a specialised [TileProvider] that uses a specialised [ImageProvider] - /// to connect to FMTC internals and enable advanced caching/retrieval logic - /// - /// Supports multiple stores, by specifying each name in [storeNames]. If an - /// empty list is specified, tiles will be fetched from all stores, but no - /// tiles will be written to any stores. For more information, see - /// [storeNames]. - /// Can be constructed alternatively with [FMTCStore.getTileProvider] to - /// support a single store. - /// - /// See other documentation for more information. - FMTCTileProvider({ + FMTCTileProvider.multipleStores({ required this.storeNames, + this.otherStoresBehavior, FMTCTileProviderSettings? settings, Map? headers, http.Client? httpClient, }) : settings = settings ?? FMTCTileProviderSettings.instance, httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), + assert( + storeNames.isNotEmpty || otherStoresBehavior != null, + '`storeNames` cannot be empty if `allStoresConfiguration` is `null`', + ), super( headers: (headers?.containsKey('User-Agent') ?? false) ? headers : _CustomUserAgentCompatMap(headers ?? {}), ); - /// Create a specialised [TileProvider] that uses a specialised [ImageProvider] - /// to connect to FMTC internals and enable advanced caching/retrieval logic - /// - /// Redirects to [FMTCTileProvider] constructor, but supports [FMTCStore] - /// instead of [String]. - FMTCTileProvider.fromStores({ - required List stores, + FMTCTileProvider.allStores({ + required StoreReadWriteBehavior allStoresConfiguration, FMTCTileProviderSettings? settings, Map? headers, http.Client? httpClient, - }) : this( - storeNames: stores.map((s) => s.storeName).toList(), + }) : this.multipleStores( + storeNames: const {}, + otherStoresBehavior: allStoresConfiguration, settings: settings, headers: headers, httpClient: httpClient, ); - /// The store names from which to fetch tiles and update tiles + /// The store names from which to (possibly) read/update/create tiles from/in /// - /// If empty, tiles will be fetched from all stores, but no tiles will be - /// written to any stores (regardless of [FMTCTileProviderSettings.behavior]). - /// This may introduce notable performance reductions, especially if failures - /// occur often or the root is particularly large, as the tile queries will - /// have unbounded constraints. + /// Keys represent store names, and the associated [StoreReadWriteBehavior] + /// represents how that store should be used. /// - /// See also: - /// - [FMTCTileProviderSettings.fallbackToAlternativeStore], which has a - /// similar behaviour, but only does so when the tile cannot be found in - /// these stores - final List? storeNames; + /// Stores not included will not be used by default. However, + /// [otherStoresBehavior] determines whether & how all other unspecified + /// stores should be used. + final Map storeNames; + + /// The behaviour of all other stores not specified in [storeNames] + /// + /// `null` means that all other stores will not be used. + /// + /// Setting a non-`null` value may reduce performance, as internal queries + /// will have fewer constraints and therefore be less efficient. + /// + /// Also see [FMTCTileProviderSettings.useOtherStoresAsFallbackOnly] for + /// whether these unspecified stores should only be used as a last resort or + /// in addition to the specified stores as normal. + final StoreReadWriteBehavior? otherStoresBehavior; /// The tile provider settings to use /// @@ -123,19 +133,28 @@ class FMTCTileProvider extends TileProvider { requireValidImage: requireValidImage, ); - /// Check whether a specified tile is cached in the current store + /// Check whether a specified tile is cached in any of the current stores + /// + /// If [storeNames] contains `null` (for example if + /// [FMTCTileProvider.allStores]) has been used, then the check is for if the + /// tile has been cached at all. Future checkTileCached({ required TileCoordinates coords, required TileLayer options, }) => FMTCBackendAccess.internal.tileExists( - storeNames: storeNames, + storeNames: _getSpecifiedStoresOrNull(), url: obscureQueryParams( url: getTileUrl(coords, options), obscuredQueryParams: settings.obscuredQueryParams, ), ); + /// If [storeNames] contains `null`, returns `null`, otherwise returns all + /// non-null names (which cannot be empty) + List? _getSpecifiedStoresOrNull() => + otherStoresBehavior != null ? null : storeNames.keys.toList(); + @override bool operator ==(Object other) => identical(this, other) || diff --git a/lib/src/providers/tile_provider_settings.dart b/lib/src/providers/tile_provider_settings.dart index 4f3f0962..999412f1 100644 --- a/lib/src/providers/tile_provider_settings.dart +++ b/lib/src/providers/tile_provider_settings.dart @@ -4,7 +4,9 @@ part of '../../flutter_map_tile_caching.dart'; /// Callback type that takes an [FMTCBrowsingError] exception -typedef FMTCBrowsingErrorHandler = void Function(FMTCBrowsingError exception); +typedef FMTCBrowsingErrorHandler = ImmutableBuffer? Function( + FMTCBrowsingError exception, +); /// Alias of [CacheBehavior] /// @@ -13,17 +15,17 @@ typedef CacheBehaviour = CacheBehavior; /// Behaviours dictating how and when browse caching should occur /// -/// | `CacheBehavior` | Preferred fetch method | Fallback fetch method | Update cache when network used | -/// |--------------------------|------------------------|-----------------------|--------------------------------| -/// | `cacheOnly` | Cache | None | | -/// | `cacheFirst` | Cache | Network | Yes | -/// | `cacheFirstNoUpdate` | Cache | Network | No | -/// | `onlineFirst` | Network | Cache | Yes | -/// | `onlineFirstNoUpdate` | Network | Cache | No | -/// | *Standard Tile Provider* | *Network* | *None* | *No* | +/// | `CacheBehavior` | Preferred fetch method | Fallback fetch method | +/// |--------------------------|------------------------|-----------------------| +/// | `cacheOnly` | Cache | None | +/// | `cacheFirst` | Cache | Network | +/// | `onlineFirst` | Network | Cache | +/// | *Standard Tile Provider* | *Network* | *None* | enum CacheBehavior { /// Only fetch tiles from the local cache /// + /// In this mode, [StoreReadWriteBehavior] is irrelevant. + /// /// Throws [FMTCBrowsingErrorType.missingInCacheOnlyMode] if a tile is /// unavailable. /// @@ -31,31 +33,40 @@ enum CacheBehavior { cacheOnly, /// Fetch tiles from the cache, falling back to the network to fetch and - /// create/update non-existent/expired tiles + /// create/update non-existent/expired tiles, dependent on the selected + /// [StoreReadWriteBehavior] /// /// See documentation on [CacheBehavior] for behavior comparison table. cacheFirst, - /// Fetch tiles from the cache, falling back to the network to fetch - /// non-existent tiles - /// - /// Never updates the cache, even if the network is used to fetch the tile. - /// - /// See documentation on [CacheBehavior] for behavior comparison table. - cacheFirstNoUpdate, - /// Fetch and create/update non-existent/expired tiles from the network, - /// falling back to the cache to fetch tiles + /// falling back to the cache to fetch tiles, dependent on the selected + /// [StoreReadWriteBehavior] /// /// See documentation on [CacheBehavior] for behavior comparison table. onlineFirst, +} + +/// Alias of [StoreReadWriteBehavior] +/// +/// ... with the correct spelling :D +typedef StoreReadWriteBehaviour = StoreReadWriteBehavior; - /// Fetch tiles from the network, falling back to the cache to fetch tiles +/// Determines the read/update/create tile behaviour of a store +enum StoreReadWriteBehavior { + /// Only read tiles + read, + + /// Read tiles, and also update existing tiles /// - /// Never updates the cache, even if the network is used to fetch the tile. + /// Unlike 'create', if (an older version of) a tile does not already exist in + /// the store, it will not be written. + readUpdate, + + /// Read, update, and create tiles /// - /// See documentation on [CacheBehavior] for behavior comparison table. - onlineFirstNoUpdate, + /// See [readUpdate] for a definition of 'update'. + readUpdateCreate, } /// Settings for an [FMTCTileProvider] @@ -69,8 +80,8 @@ class FMTCTileProviderSettings { /// To access the existing settings, if any, get [instance]. factory FMTCTileProviderSettings({ CacheBehavior behavior = CacheBehavior.cacheFirst, - bool fallbackToAlternativeStore = true, Duration cachedValidDuration = const Duration(days: 16), + bool useUnspecifiedAsLastResort = false, bool trackHitsAndMisses = true, List obscuredQueryParams = const [], FMTCBrowsingErrorHandler? errorHandler, @@ -78,9 +89,9 @@ class FMTCTileProviderSettings { }) { final settings = FMTCTileProviderSettings._( behavior: behavior, - fallbackToAlternativeStore: fallbackToAlternativeStore, - recordHitsAndMisses: trackHitsAndMisses, cachedValidDuration: cachedValidDuration, + useOtherStoresAsFallbackOnly: useUnspecifiedAsLastResort, + recordHitsAndMisses: trackHitsAndMisses, obscuredQueryParams: obscuredQueryParams.map((e) => RegExp('$e=[^&]*')), errorHandler: errorHandler, ); @@ -92,7 +103,7 @@ class FMTCTileProviderSettings { FMTCTileProviderSettings._({ required this.behavior, required this.cachedValidDuration, - required this.fallbackToAlternativeStore, + required this.useOtherStoresAsFallbackOnly, required this.recordHitsAndMisses, required this.obscuredQueryParams, required this.errorHandler, @@ -108,10 +119,9 @@ class FMTCTileProviderSettings { /// Defaults to [CacheBehavior.cacheFirst]. final CacheBehavior behavior; - /// Whether to retrieve a tile from another store if it exists, as a fallback, - /// instead of throwing an error - /// - /// Does not add tiles taken from other stores to the specified store(s). + /// Whether to only use tiles retrieved by + /// [FMTCTileProvider.otherStoresBehavior] after all specified stores have + /// been exhausted (where the tile was not present) /// /// When tiles are retrieved from other stores, it is counted as a miss for the /// specified store(s). @@ -120,11 +130,8 @@ class FMTCTileProviderSettings { /// occur often or the root is particularly large, as an extra lookup with /// unbounded constraints is required for each tile. /// - /// See details on [CacheBehavior] for information. Fallback to an alternative - /// store is always the last-resort option before throwing an error. - /// - /// Defaults to `true`. - final bool fallbackToAlternativeStore; + /// Defaults to `false`. + final bool useOtherStoresAsFallbackOnly; /// Whether to keep track of the [StoreStats.hits] and [StoreStats.misses] /// statistics @@ -160,8 +167,10 @@ class FMTCTileProviderSettings { /// A custom callback that will be called when an [FMTCBrowsingError] is raised /// - /// Even if this is defined, the error will still be (re)thrown. - void Function(FMTCBrowsingError exception)? errorHandler; + /// If no value is returned, the error will be (re)thrown as normal. However, + /// if an [ImmutableBuffer] representing an image is returned, that will be + /// displayed instead. + final FMTCBrowsingErrorHandler? errorHandler; @override bool operator ==(Object other) => @@ -169,7 +178,7 @@ class FMTCTileProviderSettings { (other is FMTCTileProviderSettings && other.behavior == behavior && other.cachedValidDuration == cachedValidDuration && - other.fallbackToAlternativeStore == fallbackToAlternativeStore && + // other.fallbackToAlternativeStore == fallbackToAlternativeStore && other.recordHitsAndMisses == recordHitsAndMisses && other.errorHandler == errorHandler && other.obscuredQueryParams == obscuredQueryParams); @@ -178,7 +187,7 @@ class FMTCTileProviderSettings { int get hashCode => Object.hashAllUnordered([ behavior, cachedValidDuration, - fallbackToAlternativeStore, + // fallbackToAlternativeStore, recordHitsAndMisses, errorHandler, obscuredQueryParams, diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index b98db404..2990b4d6 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -42,13 +42,20 @@ class FMTCStore { /// [settings] defaults to the current ambient /// [FMTCTileProviderSettings.instance], which defaults to the initial /// configuration if no other instance has been set. + /// + /// See other available [FMTCTileProvider] contructors to use multiple stores + /// at once. FMTCTileProvider getTileProvider({ + StoreReadWriteBehavior readWriteBehavior = + StoreReadWriteBehavior.readUpdateCreate, + StoreReadWriteBehavior? otherStoresBehavior, FMTCTileProviderSettings? settings, Map? headers, http.Client? httpClient, }) => - FMTCTileProvider( - storeNames: [storeName], + FMTCTileProvider.multipleStores( + storeNames: {storeName: readWriteBehavior}, + otherStoresBehavior: otherStoresBehavior, settings: settings, headers: headers, httpClient: httpClient, From 63c6704848303c4dbfc818be1d53c6ed5e60e98c Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 10 Jul 2024 23:51:57 +0100 Subject: [PATCH 09/97] Implemented initial tile loading debug system Deprecated `obscuredQueryParams` Added `urlTransformer` & `urlTransformerOmitKeyValues` [as replacement for above Refactored `_FMTCImageProvider` internals --- .../panels/behaviour/behaviour.dart | 27 +- .../src/shared/state/general_provider.dart | 21 +- lib/flutter_map_tile_caching.dart | 3 +- lib/src/backend/interfaces/models.dart | 7 +- lib/src/bulk_download/manager.dart | 4 +- lib/src/bulk_download/thread.dart | 7 +- lib/src/misc/obscure_query_params.dart | 21 -- .../allowed_notify_value_notifier.dart | 13 + lib/src/providers/image_provider.dart | 338 +++++------------- lib/src/providers/internal_get_bytes.dart | 226 ++++++++++++ lib/src/providers/tile_provider.dart | 18 +- lib/src/providers/tile_provider_settings.dart | 119 +++++- lib/src/store/download.dart | 31 +- lib/src/store/store.dart | 2 + 14 files changed, 491 insertions(+), 346 deletions(-) delete mode 100644 lib/src/misc/obscure_query_params.dart create mode 100644 lib/src/providers/allowed_notify_value_notifier.dart create mode 100644 lib/src/providers/internal_get_bytes.dart diff --git a/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart b/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart index 45bd4ef6..7497e43f 100644 --- a/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart +++ b/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.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/general_provider.dart'; @@ -9,9 +10,10 @@ class ConfigPanelBehaviour extends StatelessWidget { }); @override - Widget build(BuildContext context) => Selector( - selector: (context, provider) => provider.behaviourPrimary, - builder: (context, behaviourPrimary, _) => Column( + Widget build(BuildContext context) => + Selector( + selector: (context, provider) => provider.cacheBehavior, + builder: (context, cacheBehavior, _) => Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( @@ -19,32 +21,32 @@ class ConfigPanelBehaviour extends StatelessWidget { child: SegmentedButton( segments: const [ ButtonSegment( - value: null, + value: CacheBehavior.cacheOnly, icon: Icon(Icons.download_for_offline_outlined), label: Text('Cache Only'), ), ButtonSegment( - value: false, + value: CacheBehavior.cacheFirst, icon: Icon(Icons.storage_rounded), label: Text('Cache'), ), ButtonSegment( - value: true, + value: CacheBehavior.onlineFirst, icon: Icon(Icons.public_rounded), label: Text('Network'), ), ], - selected: {behaviourPrimary}, + selected: {cacheBehavior}, onSelectionChanged: (value) => context .read() - .behaviourPrimary = value.single, + .cacheBehavior = value.single, style: const ButtonStyle( visualDensity: VisualDensity.comfortable, ), ), ), const SizedBox(height: 6), - Selector( + /*Selector( selector: (context, provider) => provider.behaviourUpdateFromNetwork, builder: (context, behaviourUpdateFromNetwork, _) => Row( @@ -53,9 +55,8 @@ class ConfigPanelBehaviour extends StatelessWidget { const Text('Update cache when network used'), const Spacer(), Switch.adaptive( - value: - behaviourPrimary != null && behaviourUpdateFromNetwork, - onChanged: behaviourPrimary == null + value: cacheBehavior != null && behaviourUpdateFromNetwork, + onChanged: cacheBehavior == null ? null : (value) => context .read() @@ -69,7 +70,7 @@ class ConfigPanelBehaviour extends StatelessWidget { const SizedBox(width: 8), ], ), - ), + ),*/ ], ), ); diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart index 9af69539..f52b8f90 100644 --- a/example/lib/src/shared/state/general_provider.dart +++ b/example/lib/src/shared/state/general_provider.dart @@ -28,28 +28,19 @@ class GeneralProvider extends ChangeNotifier { notifyListeners(); } - CacheBehavior get behaviour => - switch ((_behaviourPrimary, _behaviourUpdateFromNetwork)) { - (null, _) => CacheBehavior.cacheOnly, - (false, false) => CacheBehavior.cacheFirstNoUpdate, - (false, true) => CacheBehavior.cacheFirst, - (true, false) => CacheBehavior.onlineFirstNoUpdate, - (true, true) => CacheBehavior.onlineFirst, - }; - - bool? _behaviourPrimary = false; - bool? get behaviourPrimary => _behaviourPrimary; - set behaviourPrimary(bool? newBehaviourPrimary) { - _behaviourPrimary = newBehaviourPrimary; + CacheBehavior _cacheBehavior = CacheBehavior.onlineFirst; + CacheBehavior get cacheBehavior => _cacheBehavior; + set cacheBehavior(CacheBehavior newCacheBehavior) { + _cacheBehavior = newCacheBehavior; notifyListeners(); } - bool _behaviourUpdateFromNetwork = true; + /*bool _behaviourUpdateFromNetwork = true; bool get behaviourUpdateFromNetwork => _behaviourUpdateFromNetwork; set behaviourUpdateFromNetwork(bool newBehaviourUpdateFromNetwork) { _behaviourUpdateFromNetwork = newBehaviourUpdateFromNetwork; notifyListeners(); - } + }*/ bool _displayDebugOverlay = true; bool get displayDebugOverlay => _displayDebugOverlay; diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index d0a9f93d..7297a680 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -35,7 +35,6 @@ import 'src/bulk_download/instance.dart'; import 'src/bulk_download/rate_limited_stream.dart'; import 'src/bulk_download/tile_loops/shared.dart'; import 'src/misc/int_extremes.dart'; -import 'src/misc/obscure_query_params.dart'; import 'src/providers/browsing_errors.dart'; export 'src/backend/export_external.dart'; @@ -46,7 +45,9 @@ part 'src/bulk_download/download_progress.dart'; part 'src/bulk_download/manager.dart'; part 'src/bulk_download/thread.dart'; part 'src/bulk_download/tile_event.dart'; +part 'src/providers/allowed_notify_value_notifier.dart'; part 'src/providers/image_provider.dart'; +part 'src/providers/internal_get_bytes.dart'; part 'src/providers/tile_provider.dart'; part 'src/providers/tile_provider_settings.dart'; part 'src/regions/base_region.dart'; diff --git a/lib/src/backend/interfaces/models.dart b/lib/src/backend/interfaces/models.dart index 0acb43a3..93b4d82c 100644 --- a/lib/src/backend/interfaces/models.dart +++ b/lib/src/backend/interfaces/models.dart @@ -5,17 +5,16 @@ import 'dart:typed_data'; import 'package:meta/meta.dart'; -import '../../misc/obscure_query_params.dart'; +import '../../../flutter_map_tile_caching.dart'; /// Represents a tile (which is never directly exposed to the user) /// /// Note that the relationship between stores and tiles is many-to-many, and /// backend implementations should fully support this. abstract base class BackendTile { - /// The representative URL of the tile + /// The storage-suitable UID of the tile /// - /// This is passed through [obscureQueryParams] before storage here, and so - /// may not be the same as the network URL. + /// This is the result of [FMTCTileProviderSettings.urlTransformer]. String get url; /// The time at which the [bytes] of this tile were last changed diff --git a/lib/src/bulk_download/manager.dart b/lib/src/bulk_download/manager.dart index 2de7ddbd..ff6cd74b 100644 --- a/lib/src/bulk_download/manager.dart +++ b/lib/src/bulk_download/manager.dart @@ -14,7 +14,7 @@ Future _downloadManager( bool skipSeaTiles, Duration? maxReportInterval, int? rateLimit, - List obscuredQueryParams, + String Function(String) urlTransformer, int? recoveryId, FMTCBackendInternalThreadSafe backend, }) input, @@ -223,7 +223,7 @@ Future _downloadManager( maxBufferLength: threadBufferLength, skipExistingTiles: input.skipExistingTiles, seaTileBytes: seaTileBytes, - obscuredQueryParams: input.obscuredQueryParams, + urlTransformer: input.urlTransformer, headers: headers, backend: threadBackend, ), diff --git a/lib/src/bulk_download/thread.dart b/lib/src/bulk_download/thread.dart index 0454eba9..577e16a7 100644 --- a/lib/src/bulk_download/thread.dart +++ b/lib/src/bulk_download/thread.dart @@ -11,7 +11,7 @@ Future _singleDownloadThread( int maxBufferLength, bool skipExistingTiles, Uint8List? seaTileBytes, - Iterable obscuredQueryParams, + String Function(String) urlTransformer, Map headers, FMTCBackendInternalThreadSafe backend, }) input, @@ -65,10 +65,7 @@ Future _singleDownloadThread( // Get new tile URL & any existing tile final networkUrl = input.options.tileProvider.getTileUrl(coordinates, input.options); - final matcherUrl = obscureQueryParams( - url: networkUrl, - obscuredQueryParams: input.obscuredQueryParams, - ); + final matcherUrl = input.urlTransformer(networkUrl); final existingTile = await input.backend.readTile( url: matcherUrl, diff --git a/lib/src/misc/obscure_query_params.dart b/lib/src/misc/obscure_query_params.dart deleted file mode 100644 index 59f35aad..00000000 --- a/lib/src/misc/obscure_query_params.dart +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:meta/meta.dart'; - -/// Removes all matches of [obscuredQueryParams] from [url] after the query -/// delimiter '?' -@internal -String obscureQueryParams({ - required String url, - required Iterable obscuredQueryParams, -}) { - if (!url.contains('?') || obscuredQueryParams.isEmpty) return url; - - String secondPartUrl = url.split('?')[1]; - for (final matcher in obscuredQueryParams) { - secondPartUrl = secondPartUrl.replaceAll(matcher, ''); - } - - return '${url.split('?')[0]}?$secondPartUrl'; -} diff --git a/lib/src/providers/allowed_notify_value_notifier.dart b/lib/src/providers/allowed_notify_value_notifier.dart new file mode 100644 index 00000000..fcc8a7f6 --- /dev/null +++ b/lib/src/providers/allowed_notify_value_notifier.dart @@ -0,0 +1,13 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../flutter_map_tile_caching.dart'; + +class _AllowedNotifyValueNotifier extends ValueNotifier { + _AllowedNotifyValueNotifier(super._value); + + // Removes the `@protected` annotation, as we want to perform this operation + // ourselves + @override + void notifyListeners() => super.notifyListeners(); +} diff --git a/lib/src/providers/image_provider.dart b/lib/src/providers/image_provider.dart index e32da650..7f670eb6 100644 --- a/lib/src/providers/image_provider.dart +++ b/lib/src/providers/image_provider.dart @@ -3,6 +3,16 @@ part of '../../flutter_map_tile_caching.dart'; +class DebugNotifierInfo { + DebugNotifierInfo._(); + + /// Indicates whether the tile completed loading successfully + /// + /// * `true`: completed + /// * `false`: errored + late final bool didComplete; +} + /// A specialised [ImageProvider] that uses FMTC internals to enable browse /// caching /// @@ -45,9 +55,21 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { _FMTCImageProvider key, ImageDecoderCallback decode, ) { + // Closed by `getBytes` + // ignore: close_sinks final chunkEvents = StreamController(); + return MultiFrameImageStreamCompleter( - codec: _loadAsync(key, chunkEvents, decode), + codec: getBytes( + coords: coords, + options: options, + provider: provider, + key: key, + chunkEvents: chunkEvents, + finishedLoadingBytes: finishedLoadingBytes, + startedLoading: startedLoading, + requireValidImage: true, + ).then(ImmutableBuffer.fromUint8List).then((v) => decode(v)), chunkEvents: chunkEvents.stream, scale: 1, debugLabel: coords.toString(), @@ -59,291 +81,98 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { ); } - Future _loadAsync( - _FMTCImageProvider key, - StreamController chunkEvents, - ImageDecoderCallback decode, - ) async { - void close() { - scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); - unawaited(chunkEvents.close()); - finishedLoadingBytes(); - } - - startedLoading(); - - final Uint8List bytes; - try { - bytes = await getBytes( - coords: coords, - options: options, - provider: provider, - chunkEvents: chunkEvents, - ); - } catch (err, stackTrace) { - close(); - if (err is FMTCBrowsingError) { - final handlerResult = provider.settings.errorHandler?.call(err); - if (handlerResult != null) { - return instantiateImageCodecFromBuffer(handlerResult); - } - } - Error.throwWithStackTrace(err, stackTrace); - } - - close(); - return decode(await ImmutableBuffer.fromUint8List(bytes)); - } - /// {@template fmtc.imageProvider.getBytes} /// Use FMTC's caching logic to get the bytes of the specific tile (at /// [coords]) with the specified [TileLayer] options and [FMTCTileProvider] /// provider /// - /// Used internally by [_FMTCImageProvider._loadAsync]. + /// Used internally by [_FMTCImageProvider.loadImage]. [loadImage] provides + /// a decoding wrapper, but is only suitable for codecs Flutter can render. /// - /// However, can also be used externally to integrate FMTC caching into a 3rd - /// party [TileProvider], other than [FMTCTileProvider]. For example, this - /// enables partial compatibility with `VectorTileProvider`s. For more details - /// about compatibility with vector tiles, check the online documentation. + /// Therefore, this method does not make any assumptions about the format + /// of the bytes, and it is up to the user to decode/render appropriately. + /// For example, this could be incorporated into another [ImageProvider] (via + /// a [TileProvider]) to integrate FMTC caching for vector tiles. /// /// --- /// - /// [requireValidImage] should be left `true` as default when the bytes will - /// form a valid image that Flutter can decode. Set it `false` when the bytes - /// are not decodable by Flutter - for example with vector tiles. Invalid - /// images are never written to the cache. If this is `true`, and the image is - /// invalid, an [FMTCBrowsingError] with sub-category + /// [key] is used to control the [ImageCache], and should be set when in a + /// context where [ImageProvider.obtainKey] is available. + /// + /// [chunkEvents] is used to improve the quality of an [ImageProvider], and + /// should be set when [MultiFrameImageStreamCompleter] is in use inside an + /// [ImageProvider.loadImage]. Note that it will be closed by this method. + /// + /// [startedLoading] & [finishedLoadingBytes] are used to indicate to + /// flutter_map when it is safe to dispose a [TileProvider], and should be set + /// when used inside a [TileProvider]'s context (such as directly or within + /// a dedicated [ImageProvider]). + /// + /// [requireValidImage] is `false` by default, but should be `true` when + /// only Flutter decodable data is being used (ie. most raster tiles) (and is + /// set `true` when used by [loadImage] internally). This provides an extra + /// layer of protection by preventing invalid data from being stored inside + /// the cache, which could cause further issues at a later point. However, this + /// may be set `false` intentionally, for example to allow for vector tiles + /// to be stored. If this is `true`, and the image is invalid, an + /// [FMTCBrowsingError] with sub-category /// [FMTCBrowsingErrorType.invalidImageData] will be thrown - if `false`, then /// FMTC will not throw an error, but Flutter will if the bytes are attempted - /// to be decoded. - /// - /// [chunkEvents] is intended to be passed when this is being used inside - /// another [ImageProvider]. Chunk events will be added to it as bytes load. - /// It will not be closed by this method. + /// to be decoded (now or at a later time). /// {@endtemplate} static Future getBytes({ required TileCoordinates coords, required TileLayer options, required FMTCTileProvider provider, + Object? key, StreamController? chunkEvents, - bool requireValidImage = true, + void Function()? startedLoading, + void Function()? finishedLoadingBytes, + bool requireValidImage = false, }) async { - void registerHit(List storeNames) { - if (provider.settings.recordHitsAndMisses) { - FMTCBackendAccess.internal - .registerHitOrMiss(storeNames: storeNames, hit: true); - } - } - - void registerMiss() { - if (provider.settings.recordHitsAndMisses) { - FMTCBackendAccess.internal.registerHitOrMiss( - storeNames: provider._getSpecifiedStoresOrNull(), // TODO: Verify - hit: false, - ); - } - } - - final networkUrl = provider.getTileUrl(coords, options); - final matcherUrl = obscureQueryParams( - url: networkUrl, - obscuredQueryParams: provider.settings.obscuredQueryParams, - ); - - final ( - tile: existingTile, - intersectedStoreNames: intersectedExistingStores, - allStoreNames: allExistingStores, - ) = await FMTCBackendAccess.internal.readTile( - url: matcherUrl, - storeNames: provider._getSpecifiedStoresOrNull(), - ); - - const useUnspecifiedAsLastResort = true; - - final tileExistsInUnspecifiedStoresOnly = existingTile != null && - useUnspecifiedAsLastResort && - provider.storeNames.keys - .toSet() - .union(allExistingStores.toSet()) - .isEmpty; + final currentTileDebugNotifierInfo = DebugNotifierInfo._(); - // Prepare a list of image bytes and prefill if there's already a cached - // tile available - Uint8List? bytes; - if (existingTile != null) bytes = existingTile.bytes; - - // If there is a cached tile that's in date available, use it - final needsUpdating = existingTile != null && - (provider.settings.behavior == CacheBehavior.onlineFirst || - (provider.settings.cachedValidDuration != Duration.zero && - DateTime.timestamp().millisecondsSinceEpoch - - existingTile.lastModified.millisecondsSinceEpoch > - provider.settings.cachedValidDuration.inMilliseconds)); - if (existingTile != null && - !needsUpdating && - !tileExistsInUnspecifiedStoresOnly) { - registerHit(intersectedExistingStores); - return bytes!; - } + void close({required bool didComplete}) { + finishedLoadingBytes?.call(); - // If a tile is not available and cache only mode is in use, just fail - // before attempting a network call - if (provider.settings.behavior == CacheBehavior.cacheOnly) { - if (tileExistsInUnspecifiedStoresOnly) { - registerMiss(); - return bytes!; + if (key != null) { + scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); } - if (existingTile == null) { - throw FMTCBrowsingError( - type: FMTCBrowsingErrorType.missingInCacheOnlyMode, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - ); + if (chunkEvents != null) { + unawaited(chunkEvents.close()); } - } - // Setup a network request for the tile & handle network exceptions - final request = http.Request('GET', Uri.parse(networkUrl)) - ..headers.addAll(provider.headers); - final http.StreamedResponse response; - try { - response = await provider.httpClient.send(request); - } catch (e) { - if (existingTile != null) { - registerMiss(); - return bytes!; - } - - throw FMTCBrowsingError( - type: e is SocketException - ? FMTCBrowsingErrorType.noConnectionDuringFetch - : FMTCBrowsingErrorType.unknownFetchException, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - request: request, - originalError: e, - ); + provider._internalTileLoadingDebugger + ?..value[coords] = + (currentTileDebugNotifierInfo..didComplete = didComplete) + ..notifyListeners(); } - // Check whether the network response is not 200 OK - if (response.statusCode != 200) { - if (existingTile != null) { - registerMiss(); - return bytes!; - } - - throw FMTCBrowsingError( - type: FMTCBrowsingErrorType.negativeFetchResponse, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - request: request, - response: response, - ); - } + startedLoading?.call(); - // Extract the image bytes from the streamed network response - final bytesBuilder = BytesBuilder(copy: false); - await for (final byte in response.stream) { - bytesBuilder.add(byte); - chunkEvents?.add( - ImageChunkEvent( - cumulativeBytesLoaded: bytesBuilder.length, - expectedTotalBytes: response.contentLength, - ), + final Uint8List bytes; + try { + bytes = await _internalGetBytes( + coords: coords, + options: options, + provider: provider, + chunkEvents: chunkEvents, + requireValidImage: requireValidImage, + currentTileDebugNotifierInfo: currentTileDebugNotifierInfo, ); - } - final responseBytes = bytesBuilder.takeBytes(); - - // Perform a secondary check to ensure that the bytes recieved actually - // encode a valid image - if (requireValidImage) { - late final Object? isValidImageData; - - try { - isValidImageData = (await (await instantiateImageCodec( - responseBytes, - targetWidth: 8, - targetHeight: 8, - )) - .getNextFrame()) - .image - .width > - 0 - ? null - : Exception('Image was decodable, but had a width of 0'); - } catch (e) { - isValidImageData = e; - } - - if (isValidImageData != null) { - if (existingTile != null) { - registerMiss(); - return bytes!; - } + } catch (err, stackTrace) { + close(didComplete: false); - throw FMTCBrowsingError( - type: FMTCBrowsingErrorType.invalidImageData, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - request: request, - response: response, - originalError: isValidImageData, - ); + if (err is FMTCBrowsingError) { + final handlerResult = provider.settings.errorHandler?.call(err); + if (handlerResult != null) return handlerResult; } - } - - // Find the stores that need to have this tile written to, depending on - // their read/write settings - // At this point, we've downloaded the tile anyway, so we might as well - // write the stores that allow it, even if the existing tile hasn't expired - final writeTileToSpecified = provider.storeNames.entries - .where( - (e) => switch (e.value) { - StoreReadWriteBehavior.read => false, - StoreReadWriteBehavior.readUpdate => - intersectedExistingStores.contains(e.key), - StoreReadWriteBehavior.readUpdateCreate => true, - }, - ) - .map((e) => e.key); - final writeTileToIntermediate = - provider.otherStoresBehavior == StoreReadWriteBehavior.readUpdate && - existingTile != null - ? writeTileToSpecified.followedBy( - intersectedExistingStores - .whereNot((e) => provider.storeNames.containsKey(e)), - ) - : writeTileToSpecified; - // Cache tile to necessary stores - if (writeTileToIntermediate.isNotEmpty || - provider.otherStoresBehavior == - StoreReadWriteBehavior.readUpdateCreate) { - unawaited( - FMTCBackendAccess.internal - .writeTile( - storeNames: writeTileToIntermediate.toSet().toList(growable: false), - writeAllNotIn: provider.otherStoresBehavior == - StoreReadWriteBehavior.readUpdateCreate - ? provider.storeNames.keys.toList(growable: false) - : null, - url: matcherUrl, - bytes: responseBytes, - ) - .then((createdIn) { - // Clear out old tiles if the maximum store length has been exceeded - // We only need to even attempt this if the number of tiles has changed - if (createdIn.isEmpty) return; - FMTCBackendAccess.internal - .removeOldestTilesAboveLimit(storeNames: createdIn); - }), - ); + Error.throwWithStackTrace(err, stackTrace); } - registerMiss(); - return responseBytes; + close(didComplete: true); + return bytes; } @override @@ -354,7 +183,6 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { bool operator ==(Object other) => identical(this, other) || (other is _FMTCImageProvider && - other.runtimeType == runtimeType && other.coords == coords && other.provider == provider && other.options == options); diff --git a/lib/src/providers/internal_get_bytes.dart b/lib/src/providers/internal_get_bytes.dart new file mode 100644 index 00000000..63a603e2 --- /dev/null +++ b/lib/src/providers/internal_get_bytes.dart @@ -0,0 +1,226 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../flutter_map_tile_caching.dart'; + +Future _internalGetBytes({ + required TileCoordinates coords, + required TileLayer options, + required FMTCTileProvider provider, + required StreamController? chunkEvents, + required bool requireValidImage, + required DebugNotifierInfo currentTileDebugNotifierInfo, // TODO +}) async { + void registerHit(List storeNames) { + if (provider.settings.recordHitsAndMisses) { + FMTCBackendAccess.internal + .registerHitOrMiss(storeNames: storeNames, hit: true); + } + } + + void registerMiss() { + if (provider.settings.recordHitsAndMisses) { + FMTCBackendAccess.internal.registerHitOrMiss( + storeNames: provider._getSpecifiedStoresOrNull(), // TODO: Verify + hit: false, + ); + } + } + + final networkUrl = provider.getTileUrl(coords, options); + final matcherUrl = provider.settings.urlTransformer(networkUrl); + + final ( + tile: existingTile, + intersectedStoreNames: intersectedExistingStores, + allStoreNames: allExistingStores, + ) = await FMTCBackendAccess.internal.readTile( + url: matcherUrl, + storeNames: provider._getSpecifiedStoresOrNull(), + ); + + final tileExistsInUnspecifiedStoresOnly = existingTile != null && + provider.settings.useOtherStoresAsFallbackOnly && + provider.storeNames.keys + .toSet() + .union( + allExistingStores.toSet(), + ) // TODO: Verify (intersect? simplify?) + .isEmpty; + + // Prepare a list of image bytes and prefill if there's already a cached + // tile available + Uint8List? bytes; + if (existingTile != null) bytes = existingTile.bytes; + + // If there is a cached tile that's in date available, use it + final needsUpdating = existingTile != null && + (provider.settings.behavior == CacheBehavior.onlineFirst || + (provider.settings.cachedValidDuration != Duration.zero && + DateTime.timestamp().millisecondsSinceEpoch - + existingTile.lastModified.millisecondsSinceEpoch > + provider.settings.cachedValidDuration.inMilliseconds)); + if (existingTile != null && + !needsUpdating && + !tileExistsInUnspecifiedStoresOnly) { + registerHit(intersectedExistingStores); + return bytes!; + } + + // If a tile is not available and cache only mode is in use, just fail + // before attempting a network call + if (provider.settings.behavior == CacheBehavior.cacheOnly) { + if (tileExistsInUnspecifiedStoresOnly) { + registerMiss(); + return bytes!; + } + if (existingTile == null) { + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.missingInCacheOnlyMode, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + ); + } + } + + // Setup a network request for the tile & handle network exceptions + final request = http.Request('GET', Uri.parse(networkUrl)) + ..headers.addAll(provider.headers); + final http.StreamedResponse response; + try { + response = await provider.httpClient.send(request); + } catch (e) { + if (existingTile != null) { + registerMiss(); + return bytes!; + } + + throw FMTCBrowsingError( + type: e is SocketException + ? FMTCBrowsingErrorType.noConnectionDuringFetch + : FMTCBrowsingErrorType.unknownFetchException, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + request: request, + originalError: e, + ); + } + + // Check whether the network response is not 200 OK + if (response.statusCode != 200) { + if (existingTile != null) { + registerMiss(); + return bytes!; + } + + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.negativeFetchResponse, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + request: request, + response: response, + ); + } + + // Extract the image bytes from the streamed network response + final bytesBuilder = BytesBuilder(copy: false); + await for (final byte in response.stream) { + bytesBuilder.add(byte); + chunkEvents?.add( + ImageChunkEvent( + cumulativeBytesLoaded: bytesBuilder.length, + expectedTotalBytes: response.contentLength, + ), + ); + } + final responseBytes = bytesBuilder.takeBytes(); + + // Perform a secondary check to ensure that the bytes recieved actually + // encode a valid image + if (requireValidImage) { + late final Object? isValidImageData; + + try { + isValidImageData = (await (await instantiateImageCodec( + responseBytes, + targetWidth: 8, + targetHeight: 8, + )) + .getNextFrame()) + .image + .width > + 0 + ? null + : Exception('Image was decodable, but had a width of 0'); + } catch (e) { + isValidImageData = e; + } + + if (isValidImageData != null) { + if (existingTile != null) { + registerMiss(); + return bytes!; + } + + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.invalidImageData, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + request: request, + response: response, + originalError: isValidImageData, + ); + } + } + + // Find the stores that need to have this tile written to, depending on + // their read/write settings + // At this point, we've downloaded the tile anyway, so we might as well + // write the stores that allow it, even if the existing tile hasn't expired + final writeTileToSpecified = provider.storeNames.entries + .where( + (e) => switch (e.value) { + StoreReadWriteBehavior.read => false, + StoreReadWriteBehavior.readUpdate => + intersectedExistingStores.contains(e.key), + StoreReadWriteBehavior.readUpdateCreate => true, + }, + ) + .map((e) => e.key); + + final writeTileToIntermediate = + provider.otherStoresBehavior == StoreReadWriteBehavior.readUpdate && + existingTile != null + ? writeTileToSpecified.followedBy( + intersectedExistingStores + .whereNot((e) => provider.storeNames.containsKey(e)), + ) + : writeTileToSpecified; + + // Cache tile to necessary stores + if (writeTileToIntermediate.isNotEmpty || + provider.otherStoresBehavior == StoreReadWriteBehavior.readUpdateCreate) { + unawaited( + FMTCBackendAccess.internal + .writeTile( + storeNames: writeTileToIntermediate.toSet().toList(growable: false), + writeAllNotIn: provider.otherStoresBehavior == + StoreReadWriteBehavior.readUpdateCreate + ? provider.storeNames.keys.toList(growable: false) + : null, + url: matcherUrl, + bytes: responseBytes, + ) + .then((createdIn) { + // Clear out old tiles if the maximum store length has been exceeded + // We only need to even attempt this if the number of tiles has changed + if (createdIn.isEmpty) return; + FMTCBackendAccess.internal + .removeOldestTilesAboveLimit(storeNames: createdIn); + }), + ); + } + + registerMiss(); + return responseBytes; +} diff --git a/lib/src/providers/tile_provider.dart b/lib/src/providers/tile_provider.dart index 2b0cb3eb..785dc13b 100644 --- a/lib/src/providers/tile_provider.dart +++ b/lib/src/providers/tile_provider.dart @@ -26,6 +26,7 @@ class FMTCTileProvider extends TileProvider { required this.storeNames, this.otherStoresBehavior, FMTCTileProviderSettings? settings, + this.tileLoadingDebugger, Map? headers, http.Client? httpClient, }) : settings = settings ?? FMTCTileProviderSettings.instance, @@ -43,12 +44,14 @@ class FMTCTileProvider extends TileProvider { FMTCTileProvider.allStores({ required StoreReadWriteBehavior allStoresConfiguration, FMTCTileProviderSettings? settings, + ValueNotifier>? tileLoadingDebugger, Map? headers, http.Client? httpClient, }) : this.multipleStores( storeNames: const {}, otherStoresBehavior: allStoresConfiguration, settings: settings, + tileLoadingDebugger: tileLoadingDebugger, headers: headers, httpClient: httpClient, ); @@ -94,7 +97,15 @@ class FMTCTileProvider extends TileProvider { /// underway. /// /// Does not include tiles loaded from session cache. - final _tilesInProgress = HashMap>.identity(); + final _tilesInProgress = HashMap>(); + + final ValueNotifier>? + tileLoadingDebugger; + + _AllowedNotifyValueNotifier>? + get _internalTileLoadingDebugger => + tileLoadingDebugger as _AllowedNotifyValueNotifier< + Map>?; @override ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => @@ -144,10 +155,7 @@ class FMTCTileProvider extends TileProvider { }) => FMTCBackendAccess.internal.tileExists( storeNames: _getSpecifiedStoresOrNull(), - url: obscureQueryParams( - url: getTileUrl(coords, options), - obscuredQueryParams: settings.obscuredQueryParams, - ), + url: settings.urlTransformer(getTileUrl(coords, options)), ); /// If [storeNames] contains `null`, returns `null`, otherwise returns all diff --git a/lib/src/providers/tile_provider_settings.dart b/lib/src/providers/tile_provider_settings.dart index 999412f1..efd5e93c 100644 --- a/lib/src/providers/tile_provider_settings.dart +++ b/lib/src/providers/tile_provider_settings.dart @@ -4,7 +4,7 @@ part of '../../flutter_map_tile_caching.dart'; /// Callback type that takes an [FMTCBrowsingError] exception -typedef FMTCBrowsingErrorHandler = ImmutableBuffer? Function( +typedef FMTCBrowsingErrorHandler = Uint8List? Function( FMTCBrowsingError exception, ); @@ -83,6 +83,16 @@ class FMTCTileProviderSettings { Duration cachedValidDuration = const Duration(days: 16), bool useUnspecifiedAsLastResort = false, bool trackHitsAndMisses = true, + String Function(String)? urlTransformer, + @Deprecated( + '`obscuredQueryParams` has been deprecated in favour of `urlTransformer`, ' + 'which provides more flexibility.\n' + 'To restore similar functioning, use ' + '`FMTCTileProviderSettings.urlTransformerOmitKeyValues`. Note that this ' + 'will apply to the entire URL, not only the query part, which may have ' + 'a different behaviour in some rare cases.\n' + 'This argument will be removed in a future version.', + ) List obscuredQueryParams = const [], FMTCBrowsingErrorHandler? errorHandler, bool setInstance = true, @@ -92,7 +102,18 @@ class FMTCTileProviderSettings { cachedValidDuration: cachedValidDuration, useOtherStoresAsFallbackOnly: useUnspecifiedAsLastResort, recordHitsAndMisses: trackHitsAndMisses, - obscuredQueryParams: obscuredQueryParams.map((e) => RegExp('$e=[^&]*')), + urlTransformer: urlTransformer ?? + (obscuredQueryParams.isNotEmpty + ? (url) { + final components = url.split('?'); + if (components.length == 1) return url; + return '${components[0]}?' + '${urlTransformerOmitKeyValues( + url: url, + keys: obscuredQueryParams, + )}'; + } + : (e) => e), errorHandler: errorHandler, ); @@ -105,7 +126,7 @@ class FMTCTileProviderSettings { required this.cachedValidDuration, required this.useOtherStoresAsFallbackOnly, required this.recordHitsAndMisses, - required this.obscuredQueryParams, + required this.urlTransformer, required this.errorHandler, }); @@ -152,44 +173,104 @@ class FMTCTileProviderSettings { /// Defaults to 16 days, set to [Duration.zero] to disable. final Duration cachedValidDuration; - /// A list of regular expressions indicating key-value pairs to be remove from - /// a URL's query parameter list + /// Method used to create a tile's storage-suitable UID from it's real URL /// - /// If using this property, it is recommended to set it globally on - /// initialisation with [FMTCTileProviderSettings], to ensure it gets applied - /// throughout. + /// The input string is the tile's URL. The output string should be a unique + /// string to that tile that will remain as stable as necessary if parts of the + /// URL not directly related to the tile image change. /// - /// Used by [obscureQueryParams] to apply to a URL. + /// To store and retrieve tiles, FMTC uses a tile's storage-suitable UID. + /// When a tile is stored, the tile URL is transformed before storage. When a + /// tile is retrieved from the cache, the tile URL is transformed before + /// retrieval. /// - /// See the [online documentation](https://fmtc.jaffaketchup.dev/usage/integration#obscuring-query-parameters) - /// for more information. - final Iterable obscuredQueryParams; + /// A storage-suitable UID is usually the tile's own real URL - although it may + /// not necessarily be. The tile URL is guaranteed to refer only to that tile + /// from that server (unless the server backend changes). + /// + /// However, some parts of the tile URL should not be stored. For example, + /// an API key transmitted as part of the query parameters should not be + /// stored - and is not storage-suitable. This is because, if the API key + /// changes, the cached tile will still use the old UID containing the old API + /// key, and thus the tile will never be retrieved from storage, even if the + /// image is the same. + /// + /// [FMTCTileProviderSettings.urlTransformerOmitKeyValues] may be used as a + /// transformer to omit entire key-value pairs from a URL where the key matches + /// one of the specified keys. + /// + /// > [!IMPORTANT] + /// > The callback should be **stateless** and **self-contained**. That is, + /// > the callback should not depend on any other tile or other state that is + /// > in memory only, and it should not use nor store any state externally or + /// > from any other scope (with the exception of the argument). This callback + /// > will be transferred to a seperate isolate when downloading, and therefore + /// > these external dependencies may not work as expected, at all, or be in + /// > the expected state. + /// + /// _Internally, the storage-suitable UID is usually referred to as the tile + /// URL (with distinction inferred)._ + /// + /// By default, the output string is the input string - that is, the + /// storage-suitable UID is the tile's real URL. + final String Function(String) urlTransformer; /// A custom callback that will be called when an [FMTCBrowsingError] is raised /// /// If no value is returned, the error will be (re)thrown as normal. However, - /// if an [ImmutableBuffer] representing an image is returned, that will be - /// displayed instead. + /// if a [Uint8List], that will be displayed instead (decoded as an image), + /// and no error will be thrown. final FMTCBrowsingErrorHandler? errorHandler; + /// Removes specified key-value pairs from the specified [url] + /// + /// Both the key itself and its associated value, for each of [keys], will be + /// omitted. + /// + /// [link] connects a key to its value (defaults to '='). [delimiter] + /// seperates two different key value pairs (defaults to '&'). + /// + /// For example, the [url] 'abc=123&xyz=987' with [keys] only containing 'abc' + /// would become '&xyz=987'. In this case, if these were query parameters, it + /// is assumed the server will be able to handle a missing first query + /// parameter. + /// + /// Matching and removal is performed by a regular expression. Does not mutate + /// input [url]. + /// + /// This is not designed to be a security mechanism, and should not be relied + /// upon as such. + static String urlTransformerOmitKeyValues({ + required String url, + required Iterable keys, + String link = '=', + String delimiter = '&', + }) { + var mutableUrl = url; + for (final key in keys) { + mutableUrl = mutableUrl.replaceAll(RegExp('$key$link[^$delimiter]*'), ''); + } + return mutableUrl; + } + @override bool operator ==(Object other) => identical(this, other) || (other is FMTCTileProviderSettings && other.behavior == behavior && other.cachedValidDuration == cachedValidDuration && - // other.fallbackToAlternativeStore == fallbackToAlternativeStore && + other.useOtherStoresAsFallbackOnly == useOtherStoresAsFallbackOnly && other.recordHitsAndMisses == recordHitsAndMisses && - other.errorHandler == errorHandler && - other.obscuredQueryParams == obscuredQueryParams); + other.urlTransformer == other.urlTransformer && + other.errorHandler == errorHandler); @override int get hashCode => Object.hashAllUnordered([ behavior, cachedValidDuration, - // fallbackToAlternativeStore, + useOtherStoresAsFallbackOnly, recordHitsAndMisses, + urlTransformer, errorHandler, - obscuredQueryParams, ]); } diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 0383b3b2..1d75c5f7 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -103,9 +103,9 @@ class StoreDownload { /// /// --- /// - /// For information about [obscuredQueryParams], see the - /// [online documentation](https://fmtc.jaffaketchup.dev/usage/integration#obscuring-query-parameters). - /// Will default to the value in the default [FMTCTileProviderSettings]. + /// For information about [urlTransformer], see the documentation on + /// [FMTCTileProviderSettings.urlTransformer]. Will default to the value in + /// the default [FMTCTileProviderSettings], else the identity function. /// /// To set additional headers, set it via [TileProvider.headers] when /// constructing the [DownloadableRegion]. @@ -123,6 +123,16 @@ class StoreDownload { int? rateLimit, Duration? maxReportInterval = const Duration(seconds: 1), bool disableRecovery = false, + String Function(String)? urlTransformer, + @Deprecated( + '`obscuredQueryParams` has been deprecated in favour of `urlTransformer`, ' + 'which provides more flexibility.\n' + 'To restore similar functioning, use ' + '`FMTCTileProviderSettings.urlTransformerOmitKeyValues`. Note that this ' + 'will apply to the entire URL, not only the query part, which may have ' + 'a different behaviour in some rare cases.\n' + 'This argument will be removed in a future version.', + ) List? obscuredQueryParams, Object instanceId = 0, }) async* { @@ -191,9 +201,18 @@ class StoreDownload { skipSeaTiles: skipSeaTiles, maxReportInterval: maxReportInterval, rateLimit: rateLimit, - obscuredQueryParams: - obscuredQueryParams?.map((e) => RegExp('$e=[^&]*')).toList() ?? - FMTCTileProviderSettings.instance.obscuredQueryParams.toList(), + urlTransformer: urlTransformer ?? + ((obscuredQueryParams?.isNotEmpty ?? false) + ? (url) { + final components = url.split('?'); + if (components.length == 1) return url; + return '${components[0]}?' + '${FMTCTileProviderSettings.urlTransformerOmitKeyValues( + url: url, + keys: obscuredQueryParams!, + )}'; + } + : FMTCTileProviderSettings.instance.urlTransformer), recoveryId: recoveryId, backend: FMTCBackendAccessThreadSafe.internal, ), diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index 2990b4d6..0aeea9e0 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -50,6 +50,7 @@ class FMTCStore { StoreReadWriteBehavior.readUpdateCreate, StoreReadWriteBehavior? otherStoresBehavior, FMTCTileProviderSettings? settings, + ValueNotifier>? tileLoadingDebugger, Map? headers, http.Client? httpClient, }) => @@ -57,6 +58,7 @@ class FMTCStore { storeNames: {storeName: readWriteBehavior}, otherStoresBehavior: otherStoresBehavior, settings: settings, + tileLoadingDebugger: tileLoadingDebugger, headers: headers, httpClient: httpClient, ); From 603e5234df26aad483708bdabfe642378401e4ff Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 11 Jul 2024 23:05:10 +0100 Subject: [PATCH 10/97] Minor meta refactoring and renaming --- lib/flutter_map_tile_caching.dart | 16 ++-- .../allowed_notify_value_notifier.dart | 2 +- lib/src/providers/debugger/debugger.dart | 16 ++++ .../{ => image_provider}/browsing_errors.dart | 2 +- .../{ => image_provider}/image_provider.dart | 14 +--- .../internal_get_bytes.dart | 4 +- .../providers/tile_provider/behaviours.dart | 65 +++++++++++++++++ .../{ => tile_provider}/tile_provider.dart | 18 ++--- .../tile_provider_settings.dart | 73 ++----------------- lib/src/store/store.dart | 3 +- 10 files changed, 113 insertions(+), 100 deletions(-) rename lib/src/providers/{ => debugger}/allowed_notify_value_notifier.dart (88%) create mode 100644 lib/src/providers/debugger/debugger.dart rename lib/src/providers/{ => image_provider}/browsing_errors.dart (99%) rename lib/src/providers/{ => image_provider}/image_provider.dart (95%) rename lib/src/providers/{ => image_provider}/internal_get_bytes.dart (98%) create mode 100644 lib/src/providers/tile_provider/behaviours.dart rename lib/src/providers/{ => tile_provider}/tile_provider.dart (94%) rename lib/src/providers/{ => tile_provider}/tile_provider_settings.dart (80%) diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 7297a680..6d434ac4 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -35,21 +35,23 @@ import 'src/bulk_download/instance.dart'; import 'src/bulk_download/rate_limited_stream.dart'; import 'src/bulk_download/tile_loops/shared.dart'; import 'src/misc/int_extremes.dart'; -import 'src/providers/browsing_errors.dart'; +import 'src/providers/image_provider/browsing_errors.dart'; export 'src/backend/export_external.dart'; -export 'src/providers/browsing_errors.dart'; +export 'src/providers/image_provider/browsing_errors.dart'; part 'src/bulk_download/control_cmds.dart'; part 'src/bulk_download/download_progress.dart'; part 'src/bulk_download/manager.dart'; part 'src/bulk_download/thread.dart'; part 'src/bulk_download/tile_event.dart'; -part 'src/providers/allowed_notify_value_notifier.dart'; -part 'src/providers/image_provider.dart'; -part 'src/providers/internal_get_bytes.dart'; -part 'src/providers/tile_provider.dart'; -part 'src/providers/tile_provider_settings.dart'; +part 'src/providers/debugger/allowed_notify_value_notifier.dart'; +part 'src/providers/debugger/debugger.dart'; +part 'src/providers/image_provider/image_provider.dart'; +part 'src/providers/image_provider/internal_get_bytes.dart'; +part 'src/providers/tile_provider/behaviours.dart'; +part 'src/providers/tile_provider/tile_provider.dart'; +part 'src/providers/tile_provider/tile_provider_settings.dart'; part 'src/regions/base_region.dart'; part 'src/regions/circle.dart'; part 'src/regions/custom_polygon.dart'; diff --git a/lib/src/providers/allowed_notify_value_notifier.dart b/lib/src/providers/debugger/allowed_notify_value_notifier.dart similarity index 88% rename from lib/src/providers/allowed_notify_value_notifier.dart rename to lib/src/providers/debugger/allowed_notify_value_notifier.dart index fcc8a7f6..de64217b 100644 --- a/lib/src/providers/allowed_notify_value_notifier.dart +++ b/lib/src/providers/debugger/allowed_notify_value_notifier.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; class _AllowedNotifyValueNotifier extends ValueNotifier { _AllowedNotifyValueNotifier(super._value); diff --git a/lib/src/providers/debugger/debugger.dart b/lib/src/providers/debugger/debugger.dart new file mode 100644 index 00000000..91183470 --- /dev/null +++ b/lib/src/providers/debugger/debugger.dart @@ -0,0 +1,16 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +typedef TileLoadingDebugMap = Map; + +class TileLoadingDebugInfo { + TileLoadingDebugInfo._(); + + /// Indicates whether the tile completed loading successfully + /// + /// * `true`: completed + /// * `false`: errored + late final bool didComplete; +} diff --git a/lib/src/providers/browsing_errors.dart b/lib/src/providers/image_provider/browsing_errors.dart similarity index 99% rename from lib/src/providers/browsing_errors.dart rename to lib/src/providers/image_provider/browsing_errors.dart index aa1b247f..ff64c508 100644 --- a/lib/src/providers/browsing_errors.dart +++ b/lib/src/providers/image_provider/browsing_errors.dart @@ -6,7 +6,7 @@ import 'package:http/http.dart'; import 'package:http/io_client.dart'; import 'package:meta/meta.dart'; -import '../../flutter_map_tile_caching.dart'; +import '../../../flutter_map_tile_caching.dart'; /// An [Exception] indicating that there was an error retrieving tiles to be /// displayed on the map diff --git a/lib/src/providers/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart similarity index 95% rename from lib/src/providers/image_provider.dart rename to lib/src/providers/image_provider/image_provider.dart index 7f670eb6..dd66932c 100644 --- a/lib/src/providers/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -1,17 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; - -class DebugNotifierInfo { - DebugNotifierInfo._(); - - /// Indicates whether the tile completed loading successfully - /// - /// * `true`: completed - /// * `false`: errored - late final bool didComplete; -} +part of '../../../flutter_map_tile_caching.dart'; /// A specialised [ImageProvider] that uses FMTC internals to enable browse /// caching @@ -130,7 +120,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { void Function()? finishedLoadingBytes, bool requireValidImage = false, }) async { - final currentTileDebugNotifierInfo = DebugNotifierInfo._(); + final currentTileDebugNotifierInfo = TileLoadingDebugInfo._(); void close({required bool didComplete}) { finishedLoadingBytes?.call(); diff --git a/lib/src/providers/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart similarity index 98% rename from lib/src/providers/internal_get_bytes.dart rename to lib/src/providers/image_provider/internal_get_bytes.dart index 63a603e2..13d02718 100644 --- a/lib/src/providers/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; Future _internalGetBytes({ required TileCoordinates coords, @@ -9,7 +9,7 @@ Future _internalGetBytes({ required FMTCTileProvider provider, required StreamController? chunkEvents, required bool requireValidImage, - required DebugNotifierInfo currentTileDebugNotifierInfo, // TODO + required TileLoadingDebugInfo currentTileDebugNotifierInfo, // TODO }) async { void registerHit(List storeNames) { if (provider.settings.recordHitsAndMisses) { diff --git a/lib/src/providers/tile_provider/behaviours.dart b/lib/src/providers/tile_provider/behaviours.dart new file mode 100644 index 00000000..dc504fa2 --- /dev/null +++ b/lib/src/providers/tile_provider/behaviours.dart @@ -0,0 +1,65 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// Alias of [CacheBehavior] +/// +/// ... with the correct spelling :D +typedef CacheBehaviour = CacheBehavior; + +/// Behaviours dictating how and when browse caching should occur +/// +/// | `CacheBehavior` | Preferred fetch method | Fallback fetch method | +/// |--------------------------|------------------------|-----------------------| +/// | `cacheOnly` | Cache | None | +/// | `cacheFirst` | Cache | Network | +/// | `onlineFirst` | Network | Cache | +/// | *Standard Tile Provider* | *Network* | *None* | +enum CacheBehavior { + /// Only fetch tiles from the local cache + /// + /// In this mode, [StoreReadWriteBehavior] is irrelevant. + /// + /// Throws [FMTCBrowsingErrorType.missingInCacheOnlyMode] if a tile is + /// unavailable. + /// + /// See documentation on [CacheBehavior] for behavior comparison table. + cacheOnly, + + /// Fetch tiles from the cache, falling back to the network to fetch and + /// create/update non-existent/expired tiles, dependent on the selected + /// [StoreReadWriteBehavior] + /// + /// See documentation on [CacheBehavior] for behavior comparison table. + cacheFirst, + + /// Fetch and create/update non-existent/expired tiles from the network, + /// falling back to the cache to fetch tiles, dependent on the selected + /// [StoreReadWriteBehavior] + /// + /// See documentation on [CacheBehavior] for behavior comparison table. + onlineFirst, +} + +/// Alias of [StoreReadWriteBehavior] +/// +/// ... with the correct spelling :D +typedef StoreReadWriteBehaviour = StoreReadWriteBehavior; + +/// Determines the read/update/create tile behaviour of a store +enum StoreReadWriteBehavior { + /// Only read tiles + read, + + /// Read tiles, and also update existing tiles + /// + /// Unlike 'create', if (an older version of) a tile does not already exist in + /// the store, it will not be written. + readUpdate, + + /// Read, update, and create tiles + /// + /// See [readUpdate] for a definition of 'update'. + readUpdateCreate, +} diff --git a/lib/src/providers/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart similarity index 94% rename from lib/src/providers/tile_provider.dart rename to lib/src/providers/tile_provider/tile_provider.dart index 785dc13b..87ece7eb 100644 --- a/lib/src/providers/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; /// Specialised [TileProvider] that uses a specialised [ImageProvider] to connect /// to FMTC internals and enable advanced caching/retrieval logic @@ -44,7 +44,8 @@ class FMTCTileProvider extends TileProvider { FMTCTileProvider.allStores({ required StoreReadWriteBehavior allStoresConfiguration, FMTCTileProviderSettings? settings, - ValueNotifier>? tileLoadingDebugger, + ValueNotifier>? + tileLoadingDebugger, Map? headers, http.Client? httpClient, }) : this.multipleStores( @@ -90,6 +91,9 @@ class FMTCTileProvider extends TileProvider { /// Defaults to a standard [IOClient]/[HttpClient]. final http.Client httpClient; + final ValueNotifier>? + tileLoadingDebugger; + /// Each [Completer] is completed once the corresponding tile has finished /// loading /// @@ -99,13 +103,9 @@ class FMTCTileProvider extends TileProvider { /// Does not include tiles loaded from session cache. final _tilesInProgress = HashMap>(); - final ValueNotifier>? - tileLoadingDebugger; - - _AllowedNotifyValueNotifier>? - get _internalTileLoadingDebugger => - tileLoadingDebugger as _AllowedNotifyValueNotifier< - Map>?; + _AllowedNotifyValueNotifier? + get _internalTileLoadingDebugger => tileLoadingDebugger + as _AllowedNotifyValueNotifier?; @override ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => diff --git a/lib/src/providers/tile_provider_settings.dart b/lib/src/providers/tile_provider/tile_provider_settings.dart similarity index 80% rename from lib/src/providers/tile_provider_settings.dart rename to lib/src/providers/tile_provider/tile_provider_settings.dart index efd5e93c..c244cf73 100644 --- a/lib/src/providers/tile_provider_settings.dart +++ b/lib/src/providers/tile_provider/tile_provider_settings.dart @@ -1,73 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; - -/// Callback type that takes an [FMTCBrowsingError] exception -typedef FMTCBrowsingErrorHandler = Uint8List? Function( - FMTCBrowsingError exception, -); - -/// Alias of [CacheBehavior] -/// -/// ... with the correct spelling :D -typedef CacheBehaviour = CacheBehavior; - -/// Behaviours dictating how and when browse caching should occur -/// -/// | `CacheBehavior` | Preferred fetch method | Fallback fetch method | -/// |--------------------------|------------------------|-----------------------| -/// | `cacheOnly` | Cache | None | -/// | `cacheFirst` | Cache | Network | -/// | `onlineFirst` | Network | Cache | -/// | *Standard Tile Provider* | *Network* | *None* | -enum CacheBehavior { - /// Only fetch tiles from the local cache - /// - /// In this mode, [StoreReadWriteBehavior] is irrelevant. - /// - /// Throws [FMTCBrowsingErrorType.missingInCacheOnlyMode] if a tile is - /// unavailable. - /// - /// See documentation on [CacheBehavior] for behavior comparison table. - cacheOnly, - - /// Fetch tiles from the cache, falling back to the network to fetch and - /// create/update non-existent/expired tiles, dependent on the selected - /// [StoreReadWriteBehavior] - /// - /// See documentation on [CacheBehavior] for behavior comparison table. - cacheFirst, - - /// Fetch and create/update non-existent/expired tiles from the network, - /// falling back to the cache to fetch tiles, dependent on the selected - /// [StoreReadWriteBehavior] - /// - /// See documentation on [CacheBehavior] for behavior comparison table. - onlineFirst, -} - -/// Alias of [StoreReadWriteBehavior] -/// -/// ... with the correct spelling :D -typedef StoreReadWriteBehaviour = StoreReadWriteBehavior; - -/// Determines the read/update/create tile behaviour of a store -enum StoreReadWriteBehavior { - /// Only read tiles - read, - - /// Read tiles, and also update existing tiles - /// - /// Unlike 'create', if (an older version of) a tile does not already exist in - /// the store, it will not be written. - readUpdate, - - /// Read, update, and create tiles - /// - /// See [readUpdate] for a definition of 'update'. - readUpdateCreate, -} +part of '../../../flutter_map_tile_caching.dart'; /// Settings for an [FMTCTileProvider] /// @@ -274,3 +208,8 @@ class FMTCTileProviderSettings { errorHandler, ]); } + +/// Callback type that takes an [FMTCBrowsingError] exception +typedef FMTCBrowsingErrorHandler = Uint8List? Function( + FMTCBrowsingError exception, +); diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index 0aeea9e0..e7ff1aca 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -50,7 +50,8 @@ class FMTCStore { StoreReadWriteBehavior.readUpdateCreate, StoreReadWriteBehavior? otherStoresBehavior, FMTCTileProviderSettings? settings, - ValueNotifier>? tileLoadingDebugger, + ValueNotifier>? + tileLoadingDebugger, Map? headers, http.Client? httpClient, }) => From 31b8248060c4532aa516e6c70ac13338a0d2c438 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 11 Jul 2024 23:08:21 +0100 Subject: [PATCH 11/97] Minor meta refactoring and renaming --- lib/flutter_map_tile_caching.dart | 8 ++++---- lib/src/regions/{ => shapes}/circle.dart | 2 +- lib/src/regions/{ => shapes}/custom_polygon.dart | 2 +- lib/src/regions/{ => shapes}/line.dart | 2 +- lib/src/regions/{ => shapes}/rectangle.dart | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) rename lib/src/regions/{ => shapes}/circle.dart (97%) rename lib/src/regions/{ => shapes}/custom_polygon.dart (96%) rename lib/src/regions/{ => shapes}/line.dart (98%) rename lib/src/regions/{ => shapes}/rectangle.dart (96%) diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 6d434ac4..db01a6be 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -53,12 +53,12 @@ part 'src/providers/tile_provider/behaviours.dart'; part 'src/providers/tile_provider/tile_provider.dart'; part 'src/providers/tile_provider/tile_provider_settings.dart'; part 'src/regions/base_region.dart'; -part 'src/regions/circle.dart'; -part 'src/regions/custom_polygon.dart'; part 'src/regions/downloadable_region.dart'; -part 'src/regions/line.dart'; part 'src/regions/recovered_region.dart'; -part 'src/regions/rectangle.dart'; +part 'src/regions/shapes/circle.dart'; +part 'src/regions/shapes/custom_polygon.dart'; +part 'src/regions/shapes/line.dart'; +part 'src/regions/shapes/rectangle.dart'; part 'src/root/external.dart'; part 'src/root/recovery.dart'; part 'src/root/root.dart'; diff --git a/lib/src/regions/circle.dart b/lib/src/regions/shapes/circle.dart similarity index 97% rename from lib/src/regions/circle.dart rename to lib/src/regions/shapes/circle.dart index 7f2caa56..024563a2 100644 --- a/lib/src/regions/circle.dart +++ b/lib/src/regions/shapes/circle.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; /// A geographically circular region based off a [center] coord and [radius] /// diff --git a/lib/src/regions/custom_polygon.dart b/lib/src/regions/shapes/custom_polygon.dart similarity index 96% rename from lib/src/regions/custom_polygon.dart rename to lib/src/regions/shapes/custom_polygon.dart index a82513f0..62068d93 100644 --- a/lib/src/regions/custom_polygon.dart +++ b/lib/src/regions/shapes/custom_polygon.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; /// A geographical region who's outline is defined by a list of coordinates /// diff --git a/lib/src/regions/line.dart b/lib/src/regions/shapes/line.dart similarity index 98% rename from lib/src/regions/line.dart rename to lib/src/regions/shapes/line.dart index ec727c46..7fdd6ab4 100644 --- a/lib/src/regions/line.dart +++ b/lib/src/regions/shapes/line.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; /// A geographically line/locus region based off a list of coords and a [radius] /// diff --git a/lib/src/regions/rectangle.dart b/lib/src/regions/shapes/rectangle.dart similarity index 96% rename from lib/src/regions/rectangle.dart rename to lib/src/regions/shapes/rectangle.dart index 901b5ecb..c46310f0 100644 --- a/lib/src/regions/rectangle.dart +++ b/lib/src/regions/shapes/rectangle.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; /// A geographically rectangular region based off coordinate bounds /// From 6c7480357a10735db3fd7c036b076d14f68e7cd1 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 11 Jul 2024 23:14:25 +0100 Subject: [PATCH 12/97] Minor meta refactoring and renaming --- lib/flutter_map_tile_caching.dart | 16 ++++++++-------- .../backend/impls/objectbox/backend/backend.dart | 1 - .../internal_workers/standard/cmd_type.dart | 2 ++ .../internal_workers/standard/incoming_cmd.dart | 6 ------ .../{ => external}/download_progress.dart | 2 +- .../bulk_download/{ => external}/tile_event.dart | 2 +- .../{ => internal}/control_cmds.dart | 2 +- .../bulk_download/{ => internal}/instance.dart | 0 .../bulk_download/{ => internal}/manager.dart | 2 +- .../{ => internal}/rate_limited_stream.dart | 0 lib/src/bulk_download/{ => internal}/thread.dart | 2 +- .../{ => internal}/tile_loops/count.dart | 0 .../{ => internal}/tile_loops/generate.dart | 0 .../{ => internal}/tile_loops/shared.dart | 4 ++-- test/region_tile_test.dart | 2 +- 15 files changed, 18 insertions(+), 23 deletions(-) delete mode 100644 lib/src/backend/impls/objectbox/backend/internal_workers/standard/incoming_cmd.dart rename lib/src/bulk_download/{ => external}/download_progress.dart (99%) rename lib/src/bulk_download/{ => external}/tile_event.dart (99%) rename lib/src/bulk_download/{ => internal}/control_cmds.dart (76%) rename lib/src/bulk_download/{ => internal}/instance.dart (100%) rename lib/src/bulk_download/{ => internal}/manager.dart (99%) rename lib/src/bulk_download/{ => internal}/rate_limited_stream.dart (100%) rename lib/src/bulk_download/{ => internal}/thread.dart (98%) rename lib/src/bulk_download/{ => internal}/tile_loops/count.dart (100%) rename lib/src/bulk_download/{ => internal}/tile_loops/generate.dart (100%) rename lib/src/bulk_download/{ => internal}/tile_loops/shared.dart (95%) diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index db01a6be..6df6ac2c 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -31,20 +31,20 @@ import 'package:meta/meta.dart'; import 'src/backend/export_external.dart'; import 'src/backend/export_internal.dart'; -import 'src/bulk_download/instance.dart'; -import 'src/bulk_download/rate_limited_stream.dart'; -import 'src/bulk_download/tile_loops/shared.dart'; +import 'src/bulk_download/internal/instance.dart'; +import 'src/bulk_download/internal/rate_limited_stream.dart'; +import 'src/bulk_download/internal/tile_loops/shared.dart'; import 'src/misc/int_extremes.dart'; import 'src/providers/image_provider/browsing_errors.dart'; export 'src/backend/export_external.dart'; export 'src/providers/image_provider/browsing_errors.dart'; -part 'src/bulk_download/control_cmds.dart'; -part 'src/bulk_download/download_progress.dart'; -part 'src/bulk_download/manager.dart'; -part 'src/bulk_download/thread.dart'; -part 'src/bulk_download/tile_event.dart'; +part 'src/bulk_download/external/download_progress.dart'; +part 'src/bulk_download/external/tile_event.dart'; +part 'src/bulk_download/internal/control_cmds.dart'; +part 'src/bulk_download/internal/manager.dart'; +part 'src/bulk_download/internal/thread.dart'; part 'src/providers/debugger/allowed_notify_value_notifier.dart'; part 'src/providers/debugger/debugger.dart'; part 'src/providers/image_provider/image_provider.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/backend.dart b/lib/src/backend/impls/objectbox/backend/backend.dart index 957c925f..0355abd7 100644 --- a/lib/src/backend/impls/objectbox/backend/backend.dart +++ b/lib/src/backend/impls/objectbox/backend/backend.dart @@ -26,7 +26,6 @@ import '../models/src/tile.dart'; export 'package:objectbox/objectbox.dart' show StorageException; part 'internal_workers/standard/cmd_type.dart'; -part 'internal_workers/standard/incoming_cmd.dart'; part 'internal_workers/standard/worker.dart'; part 'internal_workers/shared.dart'; part 'internal_workers/thread_safe.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart index f171f0f8..8fb68691 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart @@ -3,6 +3,8 @@ part of '../../backend.dart'; +typedef _IncomingCmd = ({int id, _CmdType type, Map args}); + enum _CmdType { initialise_, // Only valid as a request destroy, diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/incoming_cmd.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/incoming_cmd.dart deleted file mode 100644 index 38dd2db8..00000000 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/incoming_cmd.dart +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of '../../backend.dart'; - -typedef _IncomingCmd = ({int id, _CmdType type, Map args}); diff --git a/lib/src/bulk_download/download_progress.dart b/lib/src/bulk_download/external/download_progress.dart similarity index 99% rename from lib/src/bulk_download/download_progress.dart rename to lib/src/bulk_download/external/download_progress.dart index 0321af29..80eb6d68 100644 --- a/lib/src/bulk_download/download_progress.dart +++ b/lib/src/bulk_download/external/download_progress.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; /// Statistics and information about the current progress of the download /// diff --git a/lib/src/bulk_download/tile_event.dart b/lib/src/bulk_download/external/tile_event.dart similarity index 99% rename from lib/src/bulk_download/tile_event.dart rename to lib/src/bulk_download/external/tile_event.dart index 07660bbd..fbaf6558 100644 --- a/lib/src/bulk_download/tile_event.dart +++ b/lib/src/bulk_download/external/tile_event.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; /// A generalized category for [TileEventResult] enum TileEventResultCategory { diff --git a/lib/src/bulk_download/control_cmds.dart b/lib/src/bulk_download/internal/control_cmds.dart similarity index 76% rename from lib/src/bulk_download/control_cmds.dart rename to lib/src/bulk_download/internal/control_cmds.dart index 713e95a8..88bb4c95 100644 --- a/lib/src/bulk_download/control_cmds.dart +++ b/lib/src/bulk_download/internal/control_cmds.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; enum _DownloadManagerControlCmd { cancel, diff --git a/lib/src/bulk_download/instance.dart b/lib/src/bulk_download/internal/instance.dart similarity index 100% rename from lib/src/bulk_download/instance.dart rename to lib/src/bulk_download/internal/instance.dart diff --git a/lib/src/bulk_download/manager.dart b/lib/src/bulk_download/internal/manager.dart similarity index 99% rename from lib/src/bulk_download/manager.dart rename to lib/src/bulk_download/internal/manager.dart index ff6cd74b..3604e88b 100644 --- a/lib/src/bulk_download/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; Future _downloadManager( ({ diff --git a/lib/src/bulk_download/rate_limited_stream.dart b/lib/src/bulk_download/internal/rate_limited_stream.dart similarity index 100% rename from lib/src/bulk_download/rate_limited_stream.dart rename to lib/src/bulk_download/internal/rate_limited_stream.dart diff --git a/lib/src/bulk_download/thread.dart b/lib/src/bulk_download/internal/thread.dart similarity index 98% rename from lib/src/bulk_download/thread.dart rename to lib/src/bulk_download/internal/thread.dart index 577e16a7..455d2f23 100644 --- a/lib/src/bulk_download/thread.dart +++ b/lib/src/bulk_download/internal/thread.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of '../../flutter_map_tile_caching.dart'; +part of '../../../flutter_map_tile_caching.dart'; Future _singleDownloadThread( ({ diff --git a/lib/src/bulk_download/tile_loops/count.dart b/lib/src/bulk_download/internal/tile_loops/count.dart similarity index 100% rename from lib/src/bulk_download/tile_loops/count.dart rename to lib/src/bulk_download/internal/tile_loops/count.dart diff --git a/lib/src/bulk_download/tile_loops/generate.dart b/lib/src/bulk_download/internal/tile_loops/generate.dart similarity index 100% rename from lib/src/bulk_download/tile_loops/generate.dart rename to lib/src/bulk_download/internal/tile_loops/generate.dart diff --git a/lib/src/bulk_download/tile_loops/shared.dart b/lib/src/bulk_download/internal/tile_loops/shared.dart similarity index 95% rename from lib/src/bulk_download/tile_loops/shared.dart rename to lib/src/bulk_download/internal/tile_loops/shared.dart index e8f423ec..0aecdd92 100644 --- a/lib/src/bulk_download/tile_loops/shared.dart +++ b/lib/src/bulk_download/internal/tile_loops/shared.dart @@ -12,8 +12,8 @@ import 'package:flutter_map/flutter_map.dart' hide Polygon; import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; -import '../../../flutter_map_tile_caching.dart'; -import '../../misc/int_extremes.dart'; +import '../../../../flutter_map_tile_caching.dart'; +import '../../../misc/int_extremes.dart'; part 'count.dart'; part 'generate.dart'; diff --git a/test/region_tile_test.dart b/test/region_tile_test.dart index 3ec60989..68f487a9 100644 --- a/test/region_tile_test.dart +++ b/test/region_tile_test.dart @@ -8,7 +8,7 @@ import 'dart:isolate'; import 'package:collection/collection.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:flutter_map_tile_caching/src/bulk_download/tile_loops/shared.dart'; +import 'package:flutter_map_tile_caching/src/bulk_download/internal/tile_loops/shared.dart'; import 'package:latlong2/latlong.dart'; import 'package:test/test.dart'; From 2db92df30e3c037637f7a66426bd12cee4fd5d2a Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 14 Jul 2024 20:50:34 +0100 Subject: [PATCH 13/97] Added `FMTCTileProvider.tileLoadingDebugger` and related classes Added `DebuggingTileBuilder` to example app Fixed bugs --- example/lib/main.dart | 8 + .../panels/stores/components/list.dart | 20 +-- .../components/debugging_tile_builder.dart | 160 ++++++++++++++++++ .../region_selection}/crosshairs.dart | 0 .../custom_polygon_snapping_indicator.dart | 2 +- .../region_selection}/region_shape.dart | 2 +- .../additional_panes/additional_pane.dart | 0 .../adjust_zoom_lvls_pane.dart | 0 .../additional_panes/line_region_pane.dart | 0 .../additional_panes/slider_panel_base.dart | 0 .../side_panel/custom_slider_track_shape.dart | 0 .../region_selection}/side_panel/parent.dart | 4 +- .../side_panel/primary_pane.dart | 0 .../side_panel/region_shape_button.dart | 0 .../src/screens/home/map_view/map_view.dart | 39 +++-- .../src/shared/misc/store_metadata_keys.dart | 3 +- lib/flutter_map_tile_caching.dart | 5 +- .../impls/objectbox/backend/internal.dart | 4 +- .../backend/internal_workers/shared.dart | 10 +- .../internal_workers/standard/worker.dart | 46 ++--- .../backend/interfaces/backend/internal.dart | 5 +- .../allowed_notify_value_notifier.dart | 13 -- .../image_provider/image_provider.dart | 27 +-- .../image_provider/internal_get_bytes.dart | 103 ++++++++--- .../providers/tile_loading_debug/info.dart | 83 +++++++++ .../map_typedef.dart} | 14 +- .../tile_loading_debug/result_path.dart | 24 +++ .../tile_provider/tile_provider.dart | 30 +++- lib/src/store/store.dart | 3 +- 29 files changed, 468 insertions(+), 137 deletions(-) create mode 100644 example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/crosshairs.dart (100%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/custom_polygon_snapping_indicator.dart (95%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/region_shape.dart (98%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/side_panel/additional_panes/additional_pane.dart (100%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/side_panel/additional_panes/adjust_zoom_lvls_pane.dart (100%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/side_panel/additional_panes/line_region_pane.dart (100%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/side_panel/additional_panes/slider_panel_base.dart (100%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/side_panel/custom_slider_track_shape.dart (100%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/side_panel/parent.dart (94%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/side_panel/primary_pane.dart (100%) rename example/lib/src/screens/home/map_view/{region_selection_components => components/region_selection}/side_panel/region_shape_button.dart (100%) delete mode 100644 lib/src/providers/debugger/allowed_notify_value_notifier.dart create mode 100644 lib/src/providers/tile_loading_debug/info.dart rename lib/src/providers/{debugger/debugger.dart => tile_loading_debug/map_typedef.dart} (50%) create mode 100644 lib/src/providers/tile_loading_debug/result_path.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 3f0d8e9d..c18a0006 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -9,6 +9,7 @@ import 'src/screens/home/map_view/state/region_selection_provider.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; +import 'src/shared/misc/store_metadata_keys.dart'; import 'src/shared/state/general_provider.dart'; void main() async { @@ -24,6 +25,13 @@ void main() async { } await const FMTCStore('Test Store').manage.create(); + await const FMTCStore('Test Store').metadata.setBulk( + kvs: { + StoreMetadataKeys.urlTemplate.key: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + StoreMetadataKeys.behaviour.key: CacheBehavior.cacheFirst.name, + }, + ); runApp(_AppContainer(initialisationError: initErr)); } diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/list.dart b/example/lib/src/screens/home/config_view/panels/stores/components/list.dart index 65df4042..10c2d610 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/list.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/list.dart @@ -1,5 +1,3 @@ -import 'dart:collection'; - import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; @@ -21,18 +19,14 @@ class _StoresListState extends State { FMTCRoot.stats.watchStores(triggerImmediately: true).asyncMap( (_) async { final stores = await FMTCRoot.stats.storesAvailable; - return HashMap.fromEntries( - stores.map( - (store) => MapEntry( - store, - ( - stats: store.stats.all, - metadata: store.metadata.read, - tileImage: store.stats.tileImage(size: 56), - ), + return { + for (final store in stores) + store: ( + stats: store.stats.all, + metadata: store.metadata.read, + tileImage: store.stats.tileImage(size: 56), ), - ), - ); + }; }, ); diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart b/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart new file mode 100644 index 00000000..ac4e7289 --- /dev/null +++ b/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +class DebuggingTileBuilder extends StatelessWidget { + const DebuggingTileBuilder({ + super.key, + required this.tileWidget, + required this.tile, + required this.tileLoadingDebugger, + }); + + final Widget tileWidget; + final TileImage tile; + final ValueNotifier tileLoadingDebugger; + + @override + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Colors.black.withOpacity(0.8), + width: 3, + ), + color: Colors.white.withOpacity(0.5), + ), + position: DecorationPosition.foreground, + child: tileWidget, + ), + ValueListenableBuilder( + valueListenable: tileLoadingDebugger, + builder: (context, value, _) { + final info = value[tile.coordinates]; + + if (info == null) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + + return OverflowBox( + child: Padding( + padding: const EdgeInsets.all(6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'x${tile.coordinates.x} y${tile.coordinates.y} ' + 'z${tile.coordinates.z}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + if (info.error case final error?) + Text( + error is FMTCBrowsingError + ? error.type.name + : 'Unknown error', + textAlign: TextAlign.center, + ), + if (info.result case final result?) ...[ + Text( + "'${result.name}' in " + '${tile.loadFinishedAt == null || tile.loadStarted == null ? 'Loading...' : '${tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms'}\n', + textAlign: TextAlign.center, + ), + if (info.existingStores case final existingStores?) + Text( + "Existed in: '${existingStores.join("', '")}'", + textAlign: TextAlign.center, + ) + else + const Text( + 'New tile', + textAlign: TextAlign.center, + ), + if (info.writeResult case final writeResult?) + FutureBuilder( + future: writeResult, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const Text('Caching tile...'); + } + return TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + visualDensity: VisualDensity.compact, + minimumSize: Size.zero, + ), + onPressed: () { + showDialog( + context: context, + builder: (context) => + _TileWriteResultsDialog( + results: snapshot.data!, + ), + ); + }, + child: const Text('View write result'), + ); + }, + ) + else + const Text('No write necessary'), + ], + ], + ), + ), + ); + }, + ), + ], + ); +} + +class _TileWriteResultsDialog extends StatelessWidget { + const _TileWriteResultsDialog({required this.results}); + + final Map results; + + @override + Widget build(BuildContext context) { + final newlyWritten = + results.entries.where((e) => e.value).map((e) => e.key); + final updated = results.entries.where((e) => !e.value).map((e) => e.key); + + return AlertDialog.adaptive( + title: const Text('Tile Write Results'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Newly written to: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(newlyWritten.isEmpty ? 'None' : newlyWritten.join('\n')), + const Text( + '\nUpdated in: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(updated.isEmpty ? 'None' : updated.join('\n')), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + } +} diff --git a/example/lib/src/screens/home/map_view/region_selection_components/crosshairs.dart b/example/lib/src/screens/home/map_view/components/region_selection/crosshairs.dart similarity index 100% rename from example/lib/src/screens/home/map_view/region_selection_components/crosshairs.dart rename to example/lib/src/screens/home/map_view/components/region_selection/crosshairs.dart diff --git a/example/lib/src/screens/home/map_view/region_selection_components/custom_polygon_snapping_indicator.dart b/example/lib/src/screens/home/map_view/components/region_selection/custom_polygon_snapping_indicator.dart similarity index 95% rename from example/lib/src/screens/home/map_view/region_selection_components/custom_polygon_snapping_indicator.dart rename to example/lib/src/screens/home/map_view/components/region_selection/custom_polygon_snapping_indicator.dart index c2d42f7b..6337416a 100644 --- a/example/lib/src/screens/home/map_view/region_selection_components/custom_polygon_snapping_indicator.dart +++ b/example/lib/src/screens/home/map_view/components/region_selection/custom_polygon_snapping_indicator.dart @@ -3,7 +3,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import '../state/region_selection_provider.dart'; +import '../../state/region_selection_provider.dart'; class CustomPolygonSnappingIndicator extends StatelessWidget { const CustomPolygonSnappingIndicator({ diff --git a/example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart b/example/lib/src/screens/home/map_view/components/region_selection/region_shape.dart similarity index 98% rename from example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart rename to example/lib/src/screens/home/map_view/components/region_selection/region_shape.dart index f6aea01f..070bd836 100644 --- a/example/lib/src/screens/home/map_view/region_selection_components/region_shape.dart +++ b/example/lib/src/screens/home/map_view/components/region_selection/region_shape.dart @@ -4,7 +4,7 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import '../state/region_selection_provider.dart'; +import '../../state/region_selection_provider.dart'; class RegionShape extends StatelessWidget { const RegionShape({ diff --git a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/additional_pane.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart similarity index 100% rename from example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/additional_pane.dart rename to example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart diff --git a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart similarity index 100% rename from example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart rename to example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart diff --git a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/line_region_pane.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart similarity index 100% rename from example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/line_region_pane.dart rename to example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart diff --git a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/slider_panel_base.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart similarity index 100% rename from example/lib/src/screens/home/map_view/region_selection_components/side_panel/additional_panes/slider_panel_base.dart rename to example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart diff --git a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/custom_slider_track_shape.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart similarity index 100% rename from example/lib/src/screens/home/map_view/region_selection_components/side_panel/custom_slider_track_shape.dart rename to example/lib/src/screens/home/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart diff --git a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/parent.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart similarity index 94% rename from example/lib/src/screens/home/map_view/region_selection_components/side_panel/parent.dart rename to example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart index 67ce3314..87c717f8 100644 --- a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/parent.dart +++ b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart @@ -7,8 +7,8 @@ import 'package:gpx/gpx.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import '../../../../../shared/misc/exts/interleave.dart'; -import '../../state/region_selection_provider.dart'; +import '../../../../../../shared/misc/exts/interleave.dart'; +import '../../../state/region_selection_provider.dart'; part 'additional_panes/additional_pane.dart'; part 'additional_panes/adjust_zoom_lvls_pane.dart'; diff --git a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/primary_pane.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/primary_pane.dart similarity index 100% rename from example/lib/src/screens/home/map_view/region_selection_components/side_panel/primary_pane.dart rename to example/lib/src/screens/home/map_view/components/region_selection/side_panel/primary_pane.dart diff --git a/example/lib/src/screens/home/map_view/region_selection_components/side_panel/region_shape_button.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/region_shape_button.dart similarity index 100% rename from example/lib/src/screens/home/map_view/region_selection_components/side_panel/region_shape_button.dart rename to example/lib/src/screens/home/map_view/components/region_selection/side_panel/region_shape_button.dart diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart index 4e87d601..715439b5 100644 --- a/example/lib/src/screens/home/map_view/map_view.dart +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; @@ -10,11 +9,13 @@ import 'package:provider/provider.dart'; import '../../../shared/components/loading_indicator.dart'; import '../../../shared/misc/shared_preferences.dart'; +import '../../../shared/misc/store_metadata_keys.dart'; import '../../../shared/state/general_provider.dart'; -import 'region_selection_components/crosshairs.dart'; -import 'region_selection_components/custom_polygon_snapping_indicator.dart'; -import 'region_selection_components/region_shape.dart'; -import 'region_selection_components/side_panel/parent.dart'; +import 'components/debugging_tile_builder.dart'; +import 'components/region_selection/crosshairs.dart'; +import 'components/region_selection/custom_polygon_snapping_indicator.dart'; +import 'components/region_selection/region_shape.dart'; +import 'components/region_selection/side_panel/parent.dart'; import 'state/region_selection_provider.dart'; enum MapViewMode { @@ -51,6 +52,8 @@ class _MapViewState extends State duration: MapView.animationDuration, ); + final tileLoadingDebugger = ValueNotifier({}); + @override void initState() { super.initState(); @@ -251,9 +254,10 @@ class _MapViewState extends State options: mapOptions, children: [ FutureBuilder?>( - future: currentStores.isEmpty + future: /*currentStores.isEmpty ? Future.sync(() => {}) - : FMTCStore(currentStores.first).metadata.read, + : FMTCStore(currentStores.first).metadata.read*/ + const FMTCStore('Test Store').metadata.read, builder: (context, metadata) { if (!metadata.hasData || metadata.data == null || @@ -265,14 +269,22 @@ class _MapViewState extends State final urlTemplate = currentStores.isNotEmpty && metadata.data != null - ? metadata.data!['sourceURL']! + ? metadata.data![StoreMetadataKeys.urlTemplate.key]! : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; return TileLayer( urlTemplate: urlTemplate, userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', maxNativeZoom: 20, - tileProvider: currentStores.isNotEmpty + tileProvider: const FMTCStore('Test Store').getTileProvider( + settings: FMTCTileProviderSettings( + behavior: CacheBehavior.values.byName( + metadata.data![StoreMetadataKeys.behaviour.key]!, + ), + ), + tileLoadingDebugger: tileLoadingDebugger, + ), + /*currentStores.isNotEmpty ? FMTCStore(currentStores.first).getTileProvider( settings: FMTCTileProviderSettings( behavior: CacheBehavior.values @@ -290,8 +302,15 @@ class _MapViewState extends State /*maxStoreLength: int.parse(metadata.data!['maxLength']!),*/ ), + tileLoadingDebugger: tileLoadingDebugger, ) - : NetworkTileProvider(), + : NetworkTileProvider(),*/ + tileBuilder: (context, tileWidget, tile) => + DebuggingTileBuilder( + tileLoadingDebugger: tileLoadingDebugger, + tileWidget: tileWidget, + tile: tile, + ), ); }, ), diff --git a/example/lib/src/shared/misc/store_metadata_keys.dart b/example/lib/src/shared/misc/store_metadata_keys.dart index ad2b2595..655d389a 100644 --- a/example/lib/src/shared/misc/store_metadata_keys.dart +++ b/example/lib/src/shared/misc/store_metadata_keys.dart @@ -1,5 +1,6 @@ enum StoreMetadataKeys { - urlTemplate('sourceURL'); + urlTemplate('sourceURL'), + behaviour('behaviour'); const StoreMetadataKeys(this.key); final String key; diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 6df6ac2c..a662ec35 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -45,8 +45,9 @@ part 'src/bulk_download/external/tile_event.dart'; part 'src/bulk_download/internal/control_cmds.dart'; part 'src/bulk_download/internal/manager.dart'; part 'src/bulk_download/internal/thread.dart'; -part 'src/providers/debugger/allowed_notify_value_notifier.dart'; -part 'src/providers/debugger/debugger.dart'; +part 'src/providers/tile_loading_debug/info.dart'; +part 'src/providers/tile_loading_debug/map_typedef.dart'; +part 'src/providers/tile_loading_debug/result_path.dart'; part 'src/providers/image_provider/image_provider.dart'; part 'src/providers/image_provider/internal_get_bytes.dart'; part 'src/providers/tile_provider/behaviours.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/internal.dart b/lib/src/backend/impls/objectbox/backend/internal.dart index d2a097bc..6c95520c 100644 --- a/lib/src/backend/impls/objectbox/backend/internal.dart +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -415,7 +415,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ))!['tile']; @override - Future> writeTile({ + Future> writeTile({ required String url, required Uint8List bytes, required List storeNames, @@ -429,7 +429,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { 'url': url, 'bytes': bytes, }, - ))!['newStores']; + ))!['result']; @override Future deleteTile({ diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart index 68a4bd9d..20d09c79 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart @@ -3,7 +3,7 @@ part of '../backend.dart'; -List _sharedWriteSingleTile({ +Map _sharedWriteSingleTile({ required Store root, required List storeNames, required String url, @@ -29,7 +29,7 @@ List _sharedWriteSingleTile({ final storesToUpdate = {}; - final createdIn = {}; + final result = {for (final storeName in storeNames) storeName: false}; root.runInTransaction( TxMode.write, @@ -62,7 +62,7 @@ List _sharedWriteSingleTile({ storesToUpdate.addEntries( stores.whereNot((s) => didContainAlready.contains(s.name)).map( (s) { - createdIn.add(s.name); + result[s.name] = true; return MapEntry( s.name, s @@ -83,7 +83,7 @@ List _sharedWriteSingleTile({ storesToUpdate.addEntries( stores.map( (s) { - createdIn.add(s.name); + result[s.name] = true; return MapEntry( s.name, s @@ -109,5 +109,5 @@ List _sharedWriteSingleTile({ tilesQuery.close(); storeQuery.close(); - return createdIn.toList(growable: false); + return result; } diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index c9cef236..c063d4d1 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -444,8 +444,8 @@ Future _worker( id: cmd.id, data: { 'tile': null, - 'allStoreNames': const [], - 'intersectedStoreNames': const [], + 'allStoreNames': const [], + 'intersectedStoreNames': const [], }, ); } else { @@ -495,7 +495,7 @@ Future _worker( bytes: bytes, ); - sendRes(id: cmd.id, data: {'newStores': result}); + sendRes(id: cmd.id, data: {'result': result}); case _CmdType.deleteTile: final storeName = cmd.args['storeName']! as String; final url = cmd.args['url']! as String; @@ -580,47 +580,37 @@ Future _worker( ) .build(); - final numOrphans = Map.fromIterables( - storeNames, - List.filled(storeNames.length, null), - ); - Future.wait( - List.generate( - storeNames.length, - (i) async { - final storeName = storeNames[i]; - + storeNames.map( + (storeName) async { tilesQuery.param(ObjectBoxStore_.name).value = storeName; storeQuery.param(ObjectBoxStore_.name).value = storeName; final store = storeQuery.findUnique(); - if (store == null) return; + if (store == null) return 0; final numToRemove = store.length - store.maxLength!; - - if (numToRemove <= 0) { - numOrphans[storeName] = 0; - return; - } + if (numToRemove <= 0) return 0; tilesQuery.limit = numToRemove; - final orphans = await deleteTiles( + return deleteTiles( storesQuery: storeQuery, tilesQuery: tilesQuery, ); - numOrphans[storeName] = orphans; - return; }, - growable: false, ), - ).then((_) { - sendRes(id: cmd.id, data: {'numOrphans': numOrphans}); + ).then( + (numOrphans) { + sendRes( + id: cmd.id, + data: {'numOrphans': Map.fromIterables(storeNames, numOrphans)}, + ); - storeQuery.close(); - tilesQuery.close(); - }); + storeQuery.close(); + tilesQuery.close(); + }, + ); case _CmdType.removeTilesOlderThan: final storeName = cmd.args['storeName']! as String; final expiry = cmd.args['expiry']! as DateTime; diff --git a/lib/src/backend/interfaces/backend/internal.dart b/lib/src/backend/interfaces/backend/internal.dart index 2ae4ecce..50aa4e58 100644 --- a/lib/src/backend/interfaces/backend/internal.dart +++ b/lib/src/backend/interfaces/backend/internal.dart @@ -195,8 +195,9 @@ abstract interface class FMTCBackendInternal /// Create or update a tile (given a [url] and its [bytes]) in the specified /// store /// - /// Returns the stores that the tile was created in (not already existing). - Future> writeTile({ + /// Returns all the stores that were written to, along with whether that tile + /// was new to that store (not updated). + Future> writeTile({ required String url, required Uint8List bytes, required List storeNames, diff --git a/lib/src/providers/debugger/allowed_notify_value_notifier.dart b/lib/src/providers/debugger/allowed_notify_value_notifier.dart deleted file mode 100644 index de64217b..00000000 --- a/lib/src/providers/debugger/allowed_notify_value_notifier.dart +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of '../../../flutter_map_tile_caching.dart'; - -class _AllowedNotifyValueNotifier extends ValueNotifier { - _AllowedNotifyValueNotifier(super._value); - - // Removes the `@protected` annotation, as we want to perform this operation - // ourselves - @override - void notifyListeners() => super.notifyListeners(); -} diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index dd66932c..df3dd3c4 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -7,7 +7,6 @@ part of '../../../flutter_map_tile_caching.dart'; /// caching /// /// TODO: Improve hits and misses -/// TODO: Debug tile output class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { /// Create a specialised [ImageProvider] that uses FMTC internals to enable /// browse caching @@ -120,22 +119,28 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { void Function()? finishedLoadingBytes, bool requireValidImage = false, }) async { - final currentTileDebugNotifierInfo = TileLoadingDebugInfo._(); + final currentTileDebugNotifierInfo = + provider.tileLoadingDebugger != null ? TileLoadingDebugInfo._() : null; - void close({required bool didComplete}) { + void close([Object? error]) { finishedLoadingBytes?.call(); - if (key != null) { + if (key != null && error != null) { scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); } if (chunkEvents != null) { unawaited(chunkEvents.close()); } - provider._internalTileLoadingDebugger - ?..value[coords] = - (currentTileDebugNotifierInfo..didComplete = didComplete) - ..notifyListeners(); + if (currentTileDebugNotifierInfo != null) { + currentTileDebugNotifierInfo.error = error; + if (error != null) currentTileDebugNotifierInfo.result = null; + + provider.tileLoadingDebugger! + ..value[coords] = currentTileDebugNotifierInfo + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member + ..notifyListeners(); + } } startedLoading?.call(); @@ -148,10 +153,10 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { provider: provider, chunkEvents: chunkEvents, requireValidImage: requireValidImage, - currentTileDebugNotifierInfo: currentTileDebugNotifierInfo, + currentTLDI: currentTileDebugNotifierInfo, ); } catch (err, stackTrace) { - close(didComplete: false); + close(err); if (err is FMTCBrowsingError) { final handlerResult = provider.settings.errorHandler?.call(err); @@ -161,7 +166,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { Error.throwWithStackTrace(err, stackTrace); } - close(didComplete: true); + close(); return bytes; } diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index 13d02718..17539c0b 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -9,9 +9,10 @@ Future _internalGetBytes({ required FMTCTileProvider provider, required StreamController? chunkEvents, required bool requireValidImage, - required TileLoadingDebugInfo currentTileDebugNotifierInfo, // TODO + required TileLoadingDebugInfo? currentTLDI, // TODO }) async { void registerHit(List storeNames) { + currentTLDI?.hitOrMiss = true; if (provider.settings.recordHitsAndMisses) { FMTCBackendAccess.internal .registerHitOrMiss(storeNames: storeNames, hit: true); @@ -19,6 +20,7 @@ Future _internalGetBytes({ } void registerMiss() { + currentTLDI?.hitOrMiss = false; if (provider.settings.recordHitsAndMisses) { FMTCBackendAccess.internal.registerHitOrMiss( storeNames: provider._getSpecifiedStoresOrNull(), // TODO: Verify @@ -30,6 +32,9 @@ Future _internalGetBytes({ final networkUrl = provider.getTileUrl(coords, options); final matcherUrl = provider.settings.urlTransformer(networkUrl); + currentTLDI?.networkUrl = networkUrl; + currentTLDI?.storageSuitableUID = matcherUrl; + final ( tile: existingTile, intersectedStoreNames: intersectedExistingStores, @@ -39,6 +44,9 @@ Future _internalGetBytes({ storeNames: provider._getSpecifiedStoresOrNull(), ); + currentTLDI?.existingStores = + allExistingStores.isEmpty ? null : allExistingStores; + final tileExistsInUnspecifiedStoresOnly = existingTile != null && provider.settings.useOtherStoresAsFallbackOnly && provider.storeNames.keys @@ -48,6 +56,9 @@ Future _internalGetBytes({ ) // TODO: Verify (intersect? simplify?) .isEmpty; + currentTLDI?.tileExistsInUnspecifiedStoresOnly = + tileExistsInUnspecifiedStoresOnly; + // Prepare a list of image bytes and prefill if there's already a cached // tile available Uint8List? bytes; @@ -60,9 +71,15 @@ Future _internalGetBytes({ DateTime.timestamp().millisecondsSinceEpoch - existingTile.lastModified.millisecondsSinceEpoch > provider.settings.cachedValidDuration.inMilliseconds)); + + currentTLDI?.needsUpdating = needsUpdating; + if (existingTile != null && !needsUpdating && !tileExistsInUnspecifiedStoresOnly) { + currentTLDI?.result = TileLoadingDebugResultPath.perfectFromStores; + currentTLDI?.writeResult = null; + registerHit(intersectedExistingStores); return bytes!; } @@ -70,7 +87,21 @@ Future _internalGetBytes({ // If a tile is not available and cache only mode is in use, just fail // before attempting a network call if (provider.settings.behavior == CacheBehavior.cacheOnly) { - if (tileExistsInUnspecifiedStoresOnly) { + if (existingTile != null) { + currentTLDI?.result = TileLoadingDebugResultPath.cacheOnlyFromOtherStores; + currentTLDI?.writeResult = null; + + registerMiss(); + return bytes!; + } + + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.missingInCacheOnlyMode, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + ); + + /*if (tileExistsInUnspecifiedStoresOnly) { registerMiss(); return bytes!; } @@ -80,7 +111,7 @@ Future _internalGetBytes({ networkUrl: networkUrl, matcherUrl: matcherUrl, ); - } + }*/ } // Setup a network request for the tile & handle network exceptions @@ -91,6 +122,9 @@ Future _internalGetBytes({ response = await provider.httpClient.send(request); } catch (e) { if (existingTile != null) { + currentTLDI?.result = TileLoadingDebugResultPath.noFetch; + currentTLDI?.writeResult = null; + registerMiss(); return bytes!; } @@ -109,6 +143,9 @@ Future _internalGetBytes({ // Check whether the network response is not 200 OK if (response.statusCode != 200) { if (existingTile != null) { + currentTLDI?.result = TileLoadingDebugResultPath.noFetch; + currentTLDI?.writeResult = null; + registerMiss(); return bytes!; } @@ -158,6 +195,9 @@ Future _internalGetBytes({ if (isValidImageData != null) { if (existingTile != null) { + currentTLDI?.result = TileLoadingDebugResultPath.noFetch; + currentTLDI?.writeResult = null; + registerMiss(); return bytes!; } @@ -189,38 +229,49 @@ Future _internalGetBytes({ .map((e) => e.key); final writeTileToIntermediate = - provider.otherStoresBehavior == StoreReadWriteBehavior.readUpdate && - existingTile != null - ? writeTileToSpecified.followedBy( - intersectedExistingStores - .whereNot((e) => provider.storeNames.containsKey(e)), - ) - : writeTileToSpecified; + (provider.otherStoresBehavior == StoreReadWriteBehavior.readUpdate && + existingTile != null + ? writeTileToSpecified.followedBy( + intersectedExistingStores + .whereNot((e) => provider.storeNames.containsKey(e)), + ) + : writeTileToSpecified) + .toSet() + .toList(growable: false); // Cache tile to necessary stores if (writeTileToIntermediate.isNotEmpty || provider.otherStoresBehavior == StoreReadWriteBehavior.readUpdateCreate) { - unawaited( - FMTCBackendAccess.internal - .writeTile( - storeNames: writeTileToIntermediate.toSet().toList(growable: false), - writeAllNotIn: provider.otherStoresBehavior == - StoreReadWriteBehavior.readUpdateCreate - ? provider.storeNames.keys.toList(growable: false) - : null, - url: matcherUrl, - bytes: responseBytes, - ) - .then((createdIn) { + currentTLDI?.writeResult = FMTCBackendAccess.internal.writeTile( + storeNames: writeTileToIntermediate, + writeAllNotIn: provider.otherStoresBehavior == + StoreReadWriteBehavior.readUpdateCreate + ? provider.storeNames.keys.toList(growable: false) + : null, + url: matcherUrl, + bytes: responseBytes, + // ignore: unawaited_futures + )..then((result) { + final createdIn = result.entries + .where((e) => e.value) + .map((e) => e.key) + .toList(growable: false); + // Clear out old tiles if the maximum store length has been exceeded // We only need to even attempt this if the number of tiles has changed if (createdIn.isEmpty) return; - FMTCBackendAccess.internal - .removeOldestTilesAboveLimit(storeNames: createdIn); - }), - ); + + unawaited( + FMTCBackendAccess.internal + .removeOldestTilesAboveLimit(storeNames: createdIn), + ); + }); + } else { + currentTLDI?.writeResult = null; } + currentTLDI?.result = TileLoadingDebugResultPath.fetched; + registerMiss(); return responseBytes; } diff --git a/lib/src/providers/tile_loading_debug/info.dart b/lib/src/providers/tile_loading_debug/info.dart new file mode 100644 index 00000000..6e98cbd2 --- /dev/null +++ b/lib/src/providers/tile_loading_debug/info.dart @@ -0,0 +1,83 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// Information useful to debug and record detailed statistics for the loading +/// mechanisms and paths of a tile +/// +/// When an object of this type is emitted through a [TileLoadingDebugMap], the +/// tile will have finished loading (successfully or unsuccessfully), and all +/// fields/properties will be initialised and safe to read. +class TileLoadingDebugInfo { + TileLoadingDebugInfo._(); + + /// Indicates whether & how the tile completed loading successfully + /// + /// If `null`, loading was unsuccessful. Otherwise, the + /// [TileLoadingDebugResultPath] indicates the final path point of how the + /// tile was output. + /// + /// See [didComplete] for a boolean result. If `null`, see [error] for the + /// error/exception object. + late final TileLoadingDebugResultPath? result; + + /// Indicates whether & how the tile completed loading unsuccessfully + /// + /// If `null`, loading was successful. Otherwise, the object is the + /// error/exception thrown whilst loading the tile - which is likely to be an + /// [FMTCBrowsingError]. + /// + /// See [didComplete] for a boolean result. If `null`, see [result] for the + /// exact result path. + late final Object? error; + + /// Indicates whether the tile completed loading successfully + /// + /// * `true`: completed - see [result] for exact result path + /// * `false`: errored - see [error] for error/exception object + bool get didComplete => result != null; + + /// The requested URL of the tile (based on the [TileLayer.urlTemplate]) + late final String networkUrl; + + /// The storage-suitable UID of the tile: the result of + /// [FMTCTileProviderSettings.urlTransformer] on [networkUrl] + late final String storageSuitableUID; + + /// If the tile already existed, the stores that it existed in/belonged to + late final List? existingStores; + + /// Reflection of an internal indicator of the same name + /// + /// Calculated with: + /// + /// ```dart + /// && + /// `useOtherStoresAsFallbackOnly` && + /// + /// ``` + late final bool tileExistsInUnspecifiedStoresOnly; + + /// Reflection of an internal indicator of the same name + /// + /// Calculated with: + /// + /// ```dart + /// && + /// ( + /// 'behavior' == CacheBehavior.onlineFirst || + /// + /// ) + /// ``` + late final bool needsUpdating; + + /// Whether a hit or miss was (or would have) been recorded + late final bool hitOrMiss; + + /// A mapping of all stores the tile was written to, to whether that tile was + /// newly created in that store (not updated) + /// + /// `null` if no write operation was necessary/attempted. + late final Future>? writeResult; +} diff --git a/lib/src/providers/debugger/debugger.dart b/lib/src/providers/tile_loading_debug/map_typedef.dart similarity index 50% rename from lib/src/providers/debugger/debugger.dart rename to lib/src/providers/tile_loading_debug/map_typedef.dart index 91183470..04ca0029 100644 --- a/lib/src/providers/debugger/debugger.dart +++ b/lib/src/providers/tile_loading_debug/map_typedef.dart @@ -3,14 +3,8 @@ part of '../../../flutter_map_tile_caching.dart'; +/// Mapping of [TileCoordinates] to [TileLoadingDebugInfo] +/// +/// Used within [ValueNotifier]s, which are manually updated when a tile +/// completes loading. typedef TileLoadingDebugMap = Map; - -class TileLoadingDebugInfo { - TileLoadingDebugInfo._(); - - /// Indicates whether the tile completed loading successfully - /// - /// * `true`: completed - /// * `false`: errored - late final bool didComplete; -} diff --git a/lib/src/providers/tile_loading_debug/result_path.dart b/lib/src/providers/tile_loading_debug/result_path.dart new file mode 100644 index 00000000..90e399c4 --- /dev/null +++ b/lib/src/providers/tile_loading_debug/result_path.dart @@ -0,0 +1,24 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// Methods that a tile can complete loading successfully +enum TileLoadingDebugResultPath { + /// The tile was retrieved from: + /// + /// * the specified stores + /// * the unspecified stores, if + /// [FMTCTileProviderSettings.useOtherStoresAsFallbackOnly] is `false` + perfectFromStores, + + /// The specified [CacheBehavior] was [CacheBehavior.cacheOnly], and the tile + /// was retrieved from the cache (as a fallback) + cacheOnlyFromOtherStores, + + /// The tile was retrieved from the cache as a fallback + noFetch, + + /// The tile was newly fetched from the network + fetched, +} diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index 87ece7eb..1f2451a9 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -22,6 +22,7 @@ part of '../../../flutter_map_tile_caching.dart'; /// Can be constructed alternatively with [FMTCStore.getTileProvider] to /// support a single store. class FMTCTileProvider extends TileProvider { + /// See [FMTCTileProvider] for information FMTCTileProvider.multipleStores({ required this.storeNames, this.otherStoresBehavior, @@ -41,11 +42,11 @@ class FMTCTileProvider extends TileProvider { : _CustomUserAgentCompatMap(headers ?? {}), ); + /// See [FMTCTileProvider] for information FMTCTileProvider.allStores({ required StoreReadWriteBehavior allStoresConfiguration, FMTCTileProviderSettings? settings, - ValueNotifier>? - tileLoadingDebugger, + ValueNotifier? tileLoadingDebugger, Map? headers, http.Client? httpClient, }) : this.multipleStores( @@ -91,8 +92,25 @@ class FMTCTileProvider extends TileProvider { /// Defaults to a standard [IOClient]/[HttpClient]. final http.Client httpClient; - final ValueNotifier>? - tileLoadingDebugger; + /// Allows debugging and advanced logging of internal tile loading mechanisms + /// + /// To use, first initialise a [ValueNotifier], like so, then pass it to this + /// parameter: + /// + /// ```dart + /// final tileLoadingDebugger = ValueNotifier({}); + /// // Do not use `const {}` + /// ``` + /// + /// This notifier will be notified, and the `value` updated, every time a tile + /// completes loading (successfully or unsuccessfully). The `value` maps + /// [TileCoordinates] to [TileLoadingDebugInfo]s. + /// + /// For example, this could be used to debug why tiles aren't loading as + /// expected (perhaps when used with [TileLayer.tileBuilder] & + /// [ValueListenableBuilder]), or to perform more advanced monitoring and + /// logging than the hit & miss statistics provide. + final ValueNotifier? tileLoadingDebugger; /// Each [Completer] is completed once the corresponding tile has finished /// loading @@ -103,10 +121,6 @@ class FMTCTileProvider extends TileProvider { /// Does not include tiles loaded from session cache. final _tilesInProgress = HashMap>(); - _AllowedNotifyValueNotifier? - get _internalTileLoadingDebugger => tileLoadingDebugger - as _AllowedNotifyValueNotifier?; - @override ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => _FMTCImageProvider( diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index e7ff1aca..36131b54 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -50,8 +50,7 @@ class FMTCStore { StoreReadWriteBehavior.readUpdateCreate, StoreReadWriteBehavior? otherStoresBehavior, FMTCTileProviderSettings? settings, - ValueNotifier>? - tileLoadingDebugger, + ValueNotifier? tileLoadingDebugger, Map? headers, http.Client? httpClient, }) => From d009ba7ec2c7c603fe6300881816dfa84516328f Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 15 Jul 2024 12:15:17 +0100 Subject: [PATCH 14/97] Prepare for v10.0.0-dev.2 prerelease --- CHANGELOG.md | 4 +++- .../forms/bottom_sheet/components/contents.dart | 5 +---- .../lib/src/screens/store_editor/store_editor.dart | 7 +------ example/pubspec.yaml | 8 +++----- .../providers/image_provider/browsing_errors.dart | 10 +++++----- .../image_provider/internal_get_bytes.dart | 8 ++++---- lib/src/providers/tile_provider/tile_provider.dart | 14 ++++++++++---- pubspec.yaml | 2 +- test/general_test.dart | 13 +++++++++++++ 9 files changed, 41 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efb2d88a..edf6ae9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,10 +27,12 @@ Additionally, vector tiles are now supported in theory, as the internal caching/ * Improvements to the browse caching logic and customizability * Added support for using multiple stores simultaneously in the `FMTCTileProvider`, and exposed constructor directly - * Added more `CacheBehavior` options + * Added `StoreReadWriteBehavior` for increased control over caching behaviour * Added toggle for hit/miss stat recording, to improve performance where these statistics are never read + * Added Tile Loading Debug system (`FMTCTileProvider.tileLoadingDebugger`) to provide a method to debug internal tile loading mechanisms and perform advanced custom logging * Replaced `FMTCTileProviderSettings.maxStoreLength` with a `maxLength` property on each store individually * Refactored and exposed tile provider logic into seperate `getBytes` method +* Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `urlTransformerOmitKeyValues` utility method to provide old behaviour) * Removed deprecated remnants from v9.* * Other generic improvements diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart index 285821ac..8f66dc8e 100644 --- a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart @@ -1,10 +1,7 @@ part of '../bottom_sheet.dart'; class _ContentPanels extends StatefulWidget { - const _ContentPanels({ - super.key, - required this.bottomSheetOuterController, - }); + const _ContentPanels({required this.bottomSheetOuterController}); final DraggableScrollableController bottomSheetOuterController; diff --git a/example/lib/src/screens/store_editor/store_editor.dart b/example/lib/src/screens/store_editor/store_editor.dart index a1f88170..65365532 100644 --- a/example/lib/src/screens/store_editor/store_editor.dart +++ b/example/lib/src/screens/store_editor/store_editor.dart @@ -1,15 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:http/http.dart' as http; import 'package:provider/provider.dart'; -import 'package:validators/validators.dart' as validators; -import '../../shared/components/loading_indicator.dart'; +import '../../shared/components/url_selector.dart'; import '../../shared/misc/store_metadata_keys.dart'; import '../../shared/state/general_provider.dart'; -import '../../shared/components/url_selector.dart'; class StoreEditorPopup extends StatefulWidget { const StoreEditorPopup({super.key}); diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 2b253657..1b55af04 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -14,12 +14,12 @@ dependencies: auto_size_text: ^3.0.0 badges: ^3.1.2 better_open_file: ^3.6.5 - collection: ^1.18.0 + collection: ^1.19.0 dart_earcut: ^1.1.0 - file_picker: ^8.0.3 + file_picker: ^8.0.6 flutter: sdk: flutter - flutter_map: ^7.0.1 + flutter_map: ^7.0.2 flutter_map_animations: ^0.7.0 flutter_map_tile_caching: google_fonts: ^6.2.1 @@ -39,8 +39,6 @@ dependencies: dependency_overrides: flutter_map_tile_caching: path: ../ - flutter_map: - path: D:/flutter_map flutter: uses-material-design: true diff --git a/lib/src/providers/image_provider/browsing_errors.dart b/lib/src/providers/image_provider/browsing_errors.dart index ff64c508..52c48edb 100644 --- a/lib/src/providers/image_provider/browsing_errors.dart +++ b/lib/src/providers/image_provider/browsing_errors.dart @@ -33,7 +33,7 @@ class FMTCBrowsingError implements Exception { FMTCBrowsingError({ required this.type, required this.networkUrl, - required this.matcherUrl, + required this.storageSuitableUID, this.request, this.response, this.originalError, @@ -52,12 +52,12 @@ class FMTCBrowsingError implements Exception { /// [FMTCBrowsingErrorType.explanation] & [FMTCBrowsingErrorType.resolution]. final String message; - /// Generated network URL at which the tile was requested from + /// The requested URL of the tile (based on the [TileLayer.urlTemplate]) final String networkUrl; - /// Generated URL that was used to find potential existing cached tiles, - /// taking into account [FMTCTileProviderSettings.obscuredQueryParams]. - final String matcherUrl; + /// The storage-suitable UID of the tile: the result of + /// [FMTCTileProviderSettings.urlTransformer] on [networkUrl] + final String storageSuitableUID; /// If available, the attempted HTTP request /// diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index 17539c0b..b92e837f 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -98,7 +98,7 @@ Future _internalGetBytes({ throw FMTCBrowsingError( type: FMTCBrowsingErrorType.missingInCacheOnlyMode, networkUrl: networkUrl, - matcherUrl: matcherUrl, + storageSuitableUID: matcherUrl, ); /*if (tileExistsInUnspecifiedStoresOnly) { @@ -134,7 +134,7 @@ Future _internalGetBytes({ ? FMTCBrowsingErrorType.noConnectionDuringFetch : FMTCBrowsingErrorType.unknownFetchException, networkUrl: networkUrl, - matcherUrl: matcherUrl, + storageSuitableUID: matcherUrl, request: request, originalError: e, ); @@ -153,7 +153,7 @@ Future _internalGetBytes({ throw FMTCBrowsingError( type: FMTCBrowsingErrorType.negativeFetchResponse, networkUrl: networkUrl, - matcherUrl: matcherUrl, + storageSuitableUID: matcherUrl, request: request, response: response, ); @@ -205,7 +205,7 @@ Future _internalGetBytes({ throw FMTCBrowsingError( type: FMTCBrowsingErrorType.invalidImageData, networkUrl: networkUrl, - matcherUrl: matcherUrl, + storageSuitableUID: matcherUrl, request: request, response: response, originalError: isValidImageData, diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index 1f2451a9..b264b482 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -145,16 +145,22 @@ class FMTCTileProvider extends TileProvider { /// {@macro fmtc.imageProvider.getBytes} Future getBytes({ - required TileCoordinates coordinates, + required TileCoordinates coords, required TileLayer options, + Object? key, StreamController? chunkEvents, - bool requireValidImage = true, + void Function()? startedLoading, + void Function()? finishedLoadingBytes, + bool requireValidImage = false, }) => _FMTCImageProvider.getBytes( - provider: this, + coords: coords, options: options, - coords: coordinates, + provider: this, + key: key, chunkEvents: chunkEvents, + startedLoading: startedLoading, + finishedLoadingBytes: finishedLoadingBytes, requireValidImage: requireValidImage, ); diff --git a/pubspec.yaml b/pubspec.yaml index 8f80d672..07e19b56 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 10.0.0-dev.1 +version: 10.0.0-dev.2 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues diff --git a/test/general_test.dart b/test/general_test.dart index 909f1dc3..66595b14 100644 --- a/test/general_test.dart +++ b/test/general_test.dart @@ -312,6 +312,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store1'], + writeAllNotIn: null, url: tileA64.url, bytes: tileA64.bytes, ); @@ -335,6 +336,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store1'], + writeAllNotIn: null, url: tileA64.url, bytes: tileA64.bytes, ); @@ -358,6 +360,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store1'], + writeAllNotIn: null, url: tileA128.url, bytes: tileA128.bytes, ); @@ -381,6 +384,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store1'], + writeAllNotIn: null, url: tileB64.url, bytes: tileB64.bytes, ); @@ -404,6 +408,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store1'], + writeAllNotIn: null, url: tileB128.url, bytes: tileB128.bytes, ); @@ -427,6 +432,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store1'], + writeAllNotIn: null, url: tileB64.url, bytes: tileB64.bytes, ); @@ -472,6 +478,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store2'], + writeAllNotIn: null, url: tileA64.url, bytes: tileA64.bytes, ); @@ -505,6 +512,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store2'], + writeAllNotIn: null, url: tileA128.url, bytes: tileA128.bytes, ); @@ -565,6 +573,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store2'], + writeAllNotIn: null, url: tileB64.url, bytes: tileB64.bytes, ); @@ -598,6 +607,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store2'], + writeAllNotIn: null, url: tileA64.url, bytes: tileA64.bytes, ); @@ -651,6 +661,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store1', 'store2'], + writeAllNotIn: null, url: tileA64.url, bytes: tileA64.bytes, ); @@ -684,6 +695,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store1', 'store2'], + writeAllNotIn: null, url: tileA128.url, bytes: tileA128.bytes, ); @@ -717,6 +729,7 @@ void main() { () async { await FMTCBackendAccess.internal.writeTile( storeNames: ['store1', 'store2'], + writeAllNotIn: null, url: tileB128.url, bytes: tileB128.bytes, ); From 298f8dc402550af5f4858e0d4f754680a000d4cb Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 15 Jul 2024 13:02:23 +0100 Subject: [PATCH 15/97] Removed completed TODOs --- lib/src/bulk_download/internal/manager.dart | 2 -- lib/src/providers/image_provider/image_provider.dart | 2 -- lib/src/providers/image_provider/internal_get_bytes.dart | 2 +- lib/src/store/download.dart | 7 ------- pubspec.yaml | 2 +- 5 files changed, 2 insertions(+), 13 deletions(-) diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index 3604e88b..fcadc521 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -182,8 +182,6 @@ Future _downloadManager( region: input.region, endTile: math.min(input.region.end ?? largestInt, maxTiles), ); - // TODO: Remove once validated - // send(2); } // Create convienience method to update recovery system if enabled diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index df3dd3c4..da0085a5 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -5,8 +5,6 @@ part of '../../../flutter_map_tile_caching.dart'; /// A specialised [ImageProvider] that uses FMTC internals to enable browse /// caching -/// -/// TODO: Improve hits and misses class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { /// Create a specialised [ImageProvider] that uses FMTC internals to enable /// browse caching diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index b92e837f..ff115d70 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -9,7 +9,7 @@ Future _internalGetBytes({ required FMTCTileProvider provider, required StreamController? chunkEvents, required bool requireValidImage, - required TileLoadingDebugInfo? currentTLDI, // TODO + required TileLoadingDebugInfo? currentTLDI, }) async { void registerHit(List storeNames) { currentTLDI?.hitOrMiss = true; diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 1d75c5f7..2dcdeefa 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -240,13 +240,6 @@ class StoreDownload { // Handle shutdown (both normal and cancellation) if (evt == null) break; - // Handle recovery system startup (unless disabled) - // TODO: Remove once validated - /*if (evt == 2) { - FMTCRoot.recovery._downloadsOngoing.add(recoveryId!); - continue; - }*/ - // Setup control mechanisms (senders) if (evt is SendPort) { instance diff --git a/pubspec.yaml b/pubspec.yaml index 07e19b56..70fc4c4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,7 +30,7 @@ dependencies: async: ^2.11.0 collection: ^1.18.0 dart_earcut: ^1.1.0 - flat_buffers: ^23.5.26 + #flat_buffers: ^23.5.26 flutter: sdk: flutter flutter_map: ^7.0.0 From d61250da565ef8e080e8e8a458fd15c5812c6f9a Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 15 Jul 2024 13:09:49 +0100 Subject: [PATCH 16/97] Fixed dependency versions --- example/pubspec.yaml | 2 +- pubspec.yaml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1b55af04..7fae26a3 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: auto_size_text: ^3.0.0 badges: ^3.1.2 better_open_file: ^3.6.5 - collection: ^1.19.0 + collection: ^1.18.0 dart_earcut: ^1.1.0 file_picker: ^8.0.6 flutter: diff --git a/pubspec.yaml b/pubspec.yaml index 70fc4c4d..c64da46d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,9 +28,8 @@ environment: dependencies: async: ^2.11.0 - collection: ^1.18.0 + collection: 1.18.0 dart_earcut: ^1.1.0 - #flat_buffers: ^23.5.26 flutter: sdk: flutter flutter_map: ^7.0.0 From 9d2e1de4994a7e7fe81df8be0dc0b3647b14310e Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 15 Jul 2024 22:19:41 +0100 Subject: [PATCH 17/97] Fixed discrepancy between same property name (`HitsAndMisses`) --- .../providers/tile_provider/tile_provider_settings.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/src/providers/tile_provider/tile_provider_settings.dart b/lib/src/providers/tile_provider/tile_provider_settings.dart index c244cf73..8358bbea 100644 --- a/lib/src/providers/tile_provider/tile_provider_settings.dart +++ b/lib/src/providers/tile_provider/tile_provider_settings.dart @@ -16,7 +16,7 @@ class FMTCTileProviderSettings { CacheBehavior behavior = CacheBehavior.cacheFirst, Duration cachedValidDuration = const Duration(days: 16), bool useUnspecifiedAsLastResort = false, - bool trackHitsAndMisses = true, + bool recordHitsAndMisses = true, String Function(String)? urlTransformer, @Deprecated( '`obscuredQueryParams` has been deprecated in favour of `urlTransformer`, ' @@ -35,7 +35,7 @@ class FMTCTileProviderSettings { behavior: behavior, cachedValidDuration: cachedValidDuration, useOtherStoresAsFallbackOnly: useUnspecifiedAsLastResort, - recordHitsAndMisses: trackHitsAndMisses, + recordHitsAndMisses: recordHitsAndMisses, urlTransformer: urlTransformer ?? (obscuredQueryParams.isNotEmpty ? (url) { @@ -88,8 +88,7 @@ class FMTCTileProviderSettings { /// Defaults to `false`. final bool useOtherStoresAsFallbackOnly; - /// Whether to keep track of the [StoreStats.hits] and [StoreStats.misses] - /// statistics + /// Whether to record the [StoreStats.hits] and [StoreStats.misses] statistics /// /// When enabled, hits will be recorded for all stores that the tile belonged /// to and were present in [FMTCTileProvider.storeNames], when necessary. From 756ff112ad046a8b2e435a0f1174478e021af33e Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 15 Jul 2024 22:30:34 +0100 Subject: [PATCH 18/97] Fixed accidental dependency pinning --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index c64da46d..617b6fa2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,7 +28,7 @@ environment: dependencies: async: ^2.11.0 - collection: 1.18.0 + collection: ^1.18.0 dart_earcut: ^1.1.0 flutter: sdk: flutter From 8b645bac2299abc552fac331514a44efcbf0cc08 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 15 Jul 2024 23:06:13 +0100 Subject: [PATCH 19/97] Improved quality/accuracy of circle tile generation & count algorithm Improved performance of circle tile generation algorithm *Reduced* performance of circle tile count algorithm Improved performance of tile generation algorithms with specified `end`s --- .../internal/tile_loops/count.dart | 74 ++++++---- .../internal/tile_loops/generate.dart | 135 ++++++++++++------ .../internal/tile_loops/shared.dart | 1 + test/region_tile_test.dart | 77 ++++------ 4 files changed, 167 insertions(+), 120 deletions(-) diff --git a/lib/src/bulk_download/internal/tile_loops/count.dart b/lib/src/bulk_download/internal/tile_loops/count.dart index f4c5ae39..314de290 100644 --- a/lib/src/bulk_download/internal/tile_loops/count.dart +++ b/lib/src/bulk_download/internal/tile_loops/count.dart @@ -65,39 +65,63 @@ class TileCounters { static int circleTiles(DownloadableRegion region) { region as DownloadableRegion; - final circleOutline = region.originalRegion.toOutline(); - - // Format: Map>> - final outlineTileNums = >>{}; - int numberOfTiles = 0; + final edgeTile = const Distance(roundResult: false).offset( + region.originalRegion.center, + region.originalRegion.radius * 1000, + 0, + ); + for (int zoomLvl = region.minZoom; zoomLvl <= region.maxZoom; zoomLvl++) { - outlineTileNums[zoomLvl] = {}; + final centerTile = (region.crs.latLngToPoint( + region.originalRegion.center, + zoomLvl.toDouble(), + ) / + region.options.tileSize) + .floor(); - for (final node in circleOutline) { - final tile = (region.crs.latLngToPoint(node, zoomLvl.toDouble()) / - region.options.tileSize) - .floor(); + final radius = centerTile.y - + (region.crs.latLngToPoint(edgeTile, zoomLvl.toDouble()) / + region.options.tileSize) + .floor() + .y; - outlineTileNums[zoomLvl]![tile.x] ??= [largestInt, smallestInt]; - outlineTileNums[zoomLvl]![tile.x] = [ - if (tile.y < outlineTileNums[zoomLvl]![tile.x]![0]) - tile.y - else - outlineTileNums[zoomLvl]![tile.x]![0], - if (tile.y > outlineTileNums[zoomLvl]![tile.x]![1]) - tile.y - else - outlineTileNums[zoomLvl]![tile.x]![1], - ]; + final radiusSquared = radius * radius; + + if (radius == 0) { + numberOfTiles++; + continue; } - for (final x in outlineTileNums[zoomLvl]!.keys) { - numberOfTiles += outlineTileNums[zoomLvl]![x]![1] - - outlineTileNums[zoomLvl]![x]![0] + - 1; + if (radius == 1) { + numberOfTiles += 4; + continue; } + + final generatedTiles = HashMap<(int x, int y, int z), int>(); + + for (var y = -radius; y <= radius; y++) { + bool foundTileOnAxis = false; + for (var x = -radius; x <= radius; x++) { + if ((x * x) + (y * y) <= radiusSquared) { + final a = (x + centerTile.x, y + centerTile.y, zoomLvl); + generatedTiles[a] = (generatedTiles[a] ?? 0) + 1; + final b = (x + centerTile.x, y + centerTile.y - 1, zoomLvl); + generatedTiles[b] = (generatedTiles[b] ?? 0) + 1; + final c = (x + centerTile.x - 1, y + centerTile.y, zoomLvl); + generatedTiles[c] = (generatedTiles[c] ?? 0) + 1; + final d = (x + centerTile.x - 1, y + centerTile.y - 1, zoomLvl); + generatedTiles[d] = (generatedTiles[d] ?? 0) + 1; + + foundTileOnAxis = true; + } else if (foundTileOnAxis) { + break; + } + } + } + + numberOfTiles += generatedTiles.entries.where((e) => e.value > 1).length; } return _trimToRange(region, numberOfTiles); diff --git a/lib/src/bulk_download/internal/tile_loops/generate.dart b/lib/src/bulk_download/internal/tile_loops/generate.dart index acdc4df1..53b36e7f 100644 --- a/lib/src/bulk_download/internal/tile_loops/generate.dart +++ b/lib/src/bulk_download/internal/tile_loops/generate.dart @@ -55,7 +55,9 @@ class TileGenerators { for (int x = nwPoint.x; x <= sePoint.x; x++) { for (int y = nwPoint.y; y <= sePoint.y; y++) { tileCounter++; - if (tileCounter < start || tileCounter > end) continue; + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); + await requestQueue.next; input.sendPort.send((x, y, zoomLvl.toInt())); } @@ -71,63 +73,105 @@ class TileGenerators { static Future circleTiles( ({SendPort sendPort, DownloadableRegion region}) input, ) async { - // This took some time and is fairly complicated, so this is the overall explanation: - // 1. Given a `LatLng` for every x degrees on a circle's circumference, convert it into a tile number - // 2. Using a `Map` per zoom level, record all the X values in it without duplicates - // 3. Under the previous record, add all the Y values within the circle (ie. to opposite the X value) - // 4. Loop over these XY values and add them to the list - // Theoretically, this could have been done using the same method as `lineTiles`, but `lineTiles` was built after this algorithm and this makes more sense for a circle - // Could also implement with the simpler method: - // 1. Calculate the radius in tiles using `Distance` - // 2. Iterate through y, then x - // 3. Use the circle formula x^2 + y^2 = r^2 to determine all points within the radius - // However, effectively scaling this to 256x256 tiles proved to be difficult. - final region = input.region as DownloadableRegion; - final circleOutline = region.originalRegion.toOutline(); final receivePort = ReceivePort(); input.sendPort.send(receivePort.sendPort); final requestQueue = StreamQueue(receivePort); - // Format: Map>> - final Map>> outlineTileNums = {}; - int tileCounter = -1; final start = region.start - 1; final end = (region.end ?? double.infinity) - 1; + Future sendResults(Map<(int x, int y, int z), int> results) async { + for (final MapEntry(key: coord, value: occurences) in results.entries) { + if (occurences < 2) continue; + await requestQueue.next; + input.sendPort.send(coord); + } + } + + final edgeTile = const Distance(roundResult: false).offset( + region.originalRegion.center, + region.originalRegion.radius * 1000, + 0, + ); + for (int zoomLvl = region.minZoom; zoomLvl <= region.maxZoom; zoomLvl++) { - outlineTileNums[zoomLvl] = {}; + final centerTile = (region.crs.latLngToPoint( + region.originalRegion.center, + zoomLvl.toDouble(), + ) / + region.options.tileSize) + .floor(); - for (final node in circleOutline) { - final tile = (region.crs.latLngToPoint(node, zoomLvl.toDouble()) / - region.options.tileSize) - .floor(); + final radius = centerTile.y - + (region.crs.latLngToPoint(edgeTile, zoomLvl.toDouble()) / + region.options.tileSize) + .floor() + .y; - outlineTileNums[zoomLvl]![tile.x] ??= [largestInt, smallestInt]; - outlineTileNums[zoomLvl]![tile.x] = [ - if (tile.y < outlineTileNums[zoomLvl]![tile.x]![0]) - tile.y - else - outlineTileNums[zoomLvl]![tile.x]![0], - if (tile.y > outlineTileNums[zoomLvl]![tile.x]![1]) - tile.y - else - outlineTileNums[zoomLvl]![tile.x]![1], - ]; + final radiusSquared = radius * radius; + + if (radius == 0) { + tileCounter++; + if (tileCounter < start || tileCounter > end) continue; + + await requestQueue.next; + input.sendPort.send((centerTile.x, centerTile.y, zoomLvl)); + + continue; } - for (final x in outlineTileNums[zoomLvl]!.keys) { - for (int y = outlineTileNums[zoomLvl]![x]![0]; - y <= outlineTileNums[zoomLvl]![x]![1]; - y++) { - tileCounter++; - if (tileCounter < start || tileCounter > end) continue; - await requestQueue.next; - input.sendPort.send((x, y, zoomLvl)); + if (radius == 1) { + tileCounter++; + if (tileCounter < start || tileCounter > end) continue; + + await requestQueue.next; + input.sendPort.send((centerTile.x, centerTile.y, zoomLvl)); + input.sendPort.send((centerTile.x, centerTile.y - 1, zoomLvl)); + input.sendPort.send((centerTile.x - 1, centerTile.y, zoomLvl)); + input.sendPort.send((centerTile.x - 1, centerTile.y - 1, zoomLvl)); + + continue; + } + + // Unfortunately this appears to be necessary - we only output tiles that + // have been counted more than once, otherwise they are just border tiles + // that expand the circle (although in some cases, these appear to be + // actually useful), and we have to count tiles more than once because we + // have to count 4 for each 1 + final generatedTiles = HashMap<(int x, int y, int z), int>(); + + for (var y = -radius; y <= radius; y++) { + bool foundTileOnAxis = false; + for (var x = -radius; x <= radius; x++) { + if ((x * x) + (y * y) <= radiusSquared) { + tileCounter++; + + if (tileCounter < start) continue; + if (tileCounter > end) { + await sendResults(generatedTiles); + Isolate.exit(); + } + + final a = (x + centerTile.x, y + centerTile.y, zoomLvl); + generatedTiles[a] = (generatedTiles[a] ?? 0) + 1; + final b = (x + centerTile.x, y + centerTile.y - 1, zoomLvl); + generatedTiles[b] = (generatedTiles[b] ?? 0) + 1; + final c = (x + centerTile.x - 1, y + centerTile.y, zoomLvl); + generatedTiles[c] = (generatedTiles[c] ?? 0) + 1; + final d = (x + centerTile.x - 1, y + centerTile.y - 1, zoomLvl); + generatedTiles[d] = (generatedTiles[d] ?? 0) + 1; + + foundTileOnAxis = true; + } else if (foundTileOnAxis) { + break; + } } } + + await sendResults(generatedTiles); } Isolate.exit(); @@ -259,7 +303,9 @@ class TileGenerators { bool foundOverlappingTile = false; for (int y = straightRectangleNW.y; y <= straightRectangleSE.y; y++) { tileCounter++; - if (tileCounter < start || tileCounter > end) continue; + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); + final tile = _Polygon( Point(x, y), Point(x + 1, y), @@ -267,6 +313,7 @@ class TileGenerators { Point(x, y + 1), ); if (generatedTiles.contains(tile.hashCode)) continue; + if (overlap( _Polygon( rotatedRectangleNW, @@ -363,7 +410,9 @@ class TileGenerators { for (final Point(:x, :y) in allOutlineTiles) { tileCounter++; - if (tileCounter < start || tileCounter > end) continue; + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); + await requestQueue.next; input.sendPort.send((x, y, zoomLvl.toInt())); } diff --git a/lib/src/bulk_download/internal/tile_loops/shared.dart b/lib/src/bulk_download/internal/tile_loops/shared.dart index 0aecdd92..8d20ca78 100644 --- a/lib/src/bulk_download/internal/tile_loops/shared.dart +++ b/lib/src/bulk_download/internal/tile_loops/shared.dart @@ -1,6 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE +import 'dart:collection'; import 'dart:isolate'; import 'dart:math'; diff --git a/test/region_tile_test.dart b/test/region_tile_test.dart index 68f487a9..db7e336c 100644 --- a/test/region_tile_test.dart +++ b/test/region_tile_test.dart @@ -14,7 +14,7 @@ import 'package:test/test.dart'; void main() { Future countByGenerator(DownloadableRegion region) async { - final tilereceivePort = ReceivePort(); + final tileReceivePort = ReceivePort(); final tileIsolate = await Isolate.spawn( region.when( rectangle: (_) => TileGenerators.rectangleTiles, @@ -22,15 +22,15 @@ void main() { line: (_) => TileGenerators.lineTiles, customPolygon: (_) => TileGenerators.customPolygonTiles, ), - (sendPort: tilereceivePort.sendPort, region: region), - onExit: tilereceivePort.sendPort, + (sendPort: tileReceivePort.sendPort, region: region), + onExit: tileReceivePort.sendPort, debugName: '[FMTC] Tile Coords Generator Thread', ); late final SendPort requestTilePort; int evts = -1; - await for (final evt in tilereceivePort) { + await for (final evt in tileReceivePort) { if (evt == null) break; if (evt is SendPort) requestTilePort = evt; requestTilePort.send(null); @@ -38,7 +38,7 @@ void main() { } tileIsolate.kill(priority: Isolate.immediate); - tilereceivePort.close(); + tileReceivePort.close(); return evts; } @@ -190,19 +190,35 @@ void main() { test( 'Count By Counter', - () => expect(TileCounters.circleTiles(circleRegion), 61564), + () => expect(TileCounters.circleTiles(circleRegion), 115116), ); test( 'Count By Generator', - () async => expect(await countByGenerator(circleRegion), 61564), + () async => expect(await countByGenerator(circleRegion), 115116), + ); + + test( + 'Count By Counter (Compare to Rectangle Region)', + () => expect( + TileCounters.rectangleTiles( + RectangleRegion( + // Bbox of circle + LatLngBounds( + const LatLng(1.807837, -1.79752), + const LatLng(-1.807837, 1.79752), + ), + ).toDownloadable(minZoom: 1, maxZoom: 15, options: TileLayer()), + ), + greaterThan(115116), + ), ); test( 'Counter Duration', () => print( '${List.generate( - 500, + 300, (index) { final clock = Stopwatch()..start(); TileCounters.circleTiles(circleRegion); @@ -314,17 +330,11 @@ void main() { ), ); - test( - 'Count By Generator (Compare to Rectangle Region)', - () async => - expect(await countByGenerator(customPolygonRegion2), 712096), - ); - test( 'Counter Duration', () => print( '${List.generate( - 500, + 300, (index) { final clock = Stopwatch()..start(); TileCounters.customPolygonTiles(customPolygonRegion1); @@ -349,40 +359,3 @@ void main() { timeout: const Timeout(Duration(minutes: 1)), ); } - -/* - Future> listGenerator( - DownloadableRegion region, - ) async { - final tilereceivePort = ReceivePort(); - final tileIsolate = await Isolate.spawn( - region.when( - rectangle: (_) => TilesGenerator.rectangleTiles, - circle: (_) => TilesGenerator.circleTiles, - line: (_) => TilesGenerator.lineTiles, - customPolygon: (_) => TilesGenerator.customPolygonTiles, - ), - (sendPort: tilereceivePort.sendPort, region: region), - onExit: tilereceivePort.sendPort, - debugName: '[FMTC] Tile Coords Generator Thread', - ); - late final SendPort requestTilePort; - - final Set<(int, int, int)> evts = {}; - - await for (final evt in tilereceivePort) { - if (evt == null) break; - if (evt is SendPort) { - requestTilePort = evt..send(null); - continue; - } - requestTilePort.send(null); - evts.add(evt); - } - - tileIsolate.kill(priority: Isolate.immediate); - tilereceivePort.close(); - - return evts; - } -*/ From 2a6bcacaa89272c51b06c9f16c3d09695814834f Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 16 Jul 2024 12:59:03 +0100 Subject: [PATCH 20/97] Improved circle tile generation/count algorithms --- .../internal/tile_loops/count.dart | 24 +----- .../internal/tile_loops/generate.dart | 77 ++++++++----------- test/region_tile_test.dart | 4 +- 3 files changed, 37 insertions(+), 68 deletions(-) diff --git a/lib/src/bulk_download/internal/tile_loops/count.dart b/lib/src/bulk_download/internal/tile_loops/count.dart index 314de290..358b3ab6 100644 --- a/lib/src/bulk_download/internal/tile_loops/count.dart +++ b/lib/src/bulk_download/internal/tile_loops/count.dart @@ -99,29 +99,9 @@ class TileCounters { continue; } - final generatedTiles = HashMap<(int x, int y, int z), int>(); - - for (var y = -radius; y <= radius; y++) { - bool foundTileOnAxis = false; - for (var x = -radius; x <= radius; x++) { - if ((x * x) + (y * y) <= radiusSquared) { - final a = (x + centerTile.x, y + centerTile.y, zoomLvl); - generatedTiles[a] = (generatedTiles[a] ?? 0) + 1; - final b = (x + centerTile.x, y + centerTile.y - 1, zoomLvl); - generatedTiles[b] = (generatedTiles[b] ?? 0) + 1; - final c = (x + centerTile.x - 1, y + centerTile.y, zoomLvl); - generatedTiles[c] = (generatedTiles[c] ?? 0) + 1; - final d = (x + centerTile.x - 1, y + centerTile.y - 1, zoomLvl); - generatedTiles[d] = (generatedTiles[d] ?? 0) + 1; - - foundTileOnAxis = true; - } else if (foundTileOnAxis) { - break; - } - } + for (int dy = 0; dy < radius; dy++) { + numberOfTiles += (4 * sqrt(radiusSquared - dy * dy).floor()) + 4; } - - numberOfTiles += generatedTiles.entries.where((e) => e.value > 1).length; } return _trimToRange(region, numberOfTiles); diff --git a/lib/src/bulk_download/internal/tile_loops/generate.dart b/lib/src/bulk_download/internal/tile_loops/generate.dart index 53b36e7f..8c90cc21 100644 --- a/lib/src/bulk_download/internal/tile_loops/generate.dart +++ b/lib/src/bulk_download/internal/tile_loops/generate.dart @@ -83,14 +83,6 @@ class TileGenerators { final start = region.start - 1; final end = (region.end ?? double.infinity) - 1; - Future sendResults(Map<(int x, int y, int z), int> results) async { - for (final MapEntry(key: coord, value: occurences) in results.entries) { - if (occurences < 2) continue; - await requestQueue.next; - input.sendPort.send(coord); - } - } - final edgeTile = const Distance(roundResult: false).offset( region.originalRegion.center, region.originalRegion.radius * 1000, @@ -115,7 +107,8 @@ class TileGenerators { if (radius == 0) { tileCounter++; - if (tileCounter < start || tileCounter > end) continue; + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); await requestQueue.next; input.sendPort.send((centerTile.x, centerTile.y, zoomLvl)); @@ -125,53 +118,49 @@ class TileGenerators { if (radius == 1) { tileCounter++; - if (tileCounter < start || tileCounter > end) continue; - + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); await requestQueue.next; input.sendPort.send((centerTile.x, centerTile.y, zoomLvl)); + + tileCounter++; + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); + await requestQueue.next; input.sendPort.send((centerTile.x, centerTile.y - 1, zoomLvl)); + + tileCounter++; + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); + await requestQueue.next; input.sendPort.send((centerTile.x - 1, centerTile.y, zoomLvl)); + + tileCounter++; + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); + await requestQueue.next; input.sendPort.send((centerTile.x - 1, centerTile.y - 1, zoomLvl)); continue; } - // Unfortunately this appears to be necessary - we only output tiles that - // have been counted more than once, otherwise they are just border tiles - // that expand the circle (although in some cases, these appear to be - // actually useful), and we have to count tiles more than once because we - // have to count 4 for each 1 - final generatedTiles = HashMap<(int x, int y, int z), int>(); - - for (var y = -radius; y <= radius; y++) { - bool foundTileOnAxis = false; - for (var x = -radius; x <= radius; x++) { - if ((x * x) + (y * y) <= radiusSquared) { - tileCounter++; - - if (tileCounter < start) continue; - if (tileCounter > end) { - await sendResults(generatedTiles); - Isolate.exit(); - } + for (int dy = 0; dy < radius; dy++) { + final mdx = sqrt(radiusSquared - dy * dy).floor(); + for (int dx = -mdx - 1; dx <= mdx; dx++) { + tileCounter++; + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); + await requestQueue.next; + input.sendPort.send((dx + centerTile.x, dy + centerTile.y, zoomLvl)); - final a = (x + centerTile.x, y + centerTile.y, zoomLvl); - generatedTiles[a] = (generatedTiles[a] ?? 0) + 1; - final b = (x + centerTile.x, y + centerTile.y - 1, zoomLvl); - generatedTiles[b] = (generatedTiles[b] ?? 0) + 1; - final c = (x + centerTile.x - 1, y + centerTile.y, zoomLvl); - generatedTiles[c] = (generatedTiles[c] ?? 0) + 1; - final d = (x + centerTile.x - 1, y + centerTile.y - 1, zoomLvl); - generatedTiles[d] = (generatedTiles[d] ?? 0) + 1; - - foundTileOnAxis = true; - } else if (foundTileOnAxis) { - break; - } + tileCounter++; + if (tileCounter < start) continue; + if (tileCounter > end) Isolate.exit(); + await requestQueue.next; + input.sendPort + .send((dx + centerTile.x, -dy - 1 + centerTile.y, zoomLvl)); } } - - await sendResults(generatedTiles); } Isolate.exit(); diff --git a/test/region_tile_test.dart b/test/region_tile_test.dart index db7e336c..30bf80e7 100644 --- a/test/region_tile_test.dart +++ b/test/region_tile_test.dart @@ -190,12 +190,12 @@ void main() { test( 'Count By Counter', - () => expect(TileCounters.circleTiles(circleRegion), 115116), + () => expect(TileCounters.circleTiles(circleRegion), 115912), ); test( 'Count By Generator', - () async => expect(await countByGenerator(circleRegion), 115116), + () async => expect(await countByGenerator(circleRegion), 115912), ); test( From cb2a261f9baf9caad3008eb2f84ac587f5cbe383 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 16 Jul 2024 21:51:45 +0100 Subject: [PATCH 21/97] Fixed lint & regenerated ObjectBox --- .../models/generated/objectbox.g.dart | 83 +++++++++---------- .../internal/tile_loops/shared.dart | 1 - 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart b/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart index 52e0fa96..dcbf5eb6 100644 --- a/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart +++ b/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart @@ -1,7 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// This code was generated by ObjectBox. To update it run the generator again: -// With a Flutter package, run `flutter pub run build_runner build`. -// With a Dart package, run `dart run build_runner build`. +// This code was generated by ObjectBox. To update it run the generator again +// with `dart run build_runner build`. // See also https://docs.objectbox.io/getting-started#generate-objectbox-code // ignore_for_file: camel_case_types, depend_on_referenced_packages @@ -253,13 +252,13 @@ final _entities = [ backlinks: []) ]; -/// Shortcut for [Store.new] that passes [getObjectBoxModel] and for Flutter +/// Shortcut for [obx.Store.new] that passes [getObjectBoxModel] and for Flutter /// apps by default a [directory] using `defaultStoreDirectory()` from the /// ObjectBox Flutter library. /// /// Note: for desktop apps it is recommended to specify a unique [directory]. /// -/// See [Store.new] for an explanation of all parameters. +/// See [obx.Store.new] for an explanation of all parameters. /// /// For Flutter apps, also calls `loadObjectBoxLibraryAndroidCompat()` from /// the ObjectBox Flutter library to fix loading the native ObjectBox library @@ -284,7 +283,7 @@ Future openStore( } /// Returns the ObjectBox model definition for this project for use with -/// [Store.new]. +/// [obx.Store.new]. obx_int.ModelDefinition getObjectBoxModel() { final model = obx_int.ModelInfo( entities: _entities, @@ -551,88 +550,88 @@ obx_int.ModelDefinition getObjectBoxModel() { /// [ObjectBoxRecovery] entity fields to define ObjectBox queries. class ObjectBoxRecovery_ { - /// see [ObjectBoxRecovery.id] + /// See [ObjectBoxRecovery.id]. static final id = obx.QueryIntegerProperty(_entities[0].properties[0]); - /// see [ObjectBoxRecovery.refId] + /// See [ObjectBoxRecovery.refId]. static final refId = obx.QueryIntegerProperty(_entities[0].properties[1]); - /// see [ObjectBoxRecovery.storeName] + /// See [ObjectBoxRecovery.storeName]. static final storeName = obx.QueryStringProperty(_entities[0].properties[2]); - /// see [ObjectBoxRecovery.creationTime] + /// See [ObjectBoxRecovery.creationTime]. static final creationTime = obx.QueryDateProperty(_entities[0].properties[3]); - /// see [ObjectBoxRecovery.minZoom] + /// See [ObjectBoxRecovery.minZoom]. static final minZoom = obx.QueryIntegerProperty(_entities[0].properties[4]); - /// see [ObjectBoxRecovery.maxZoom] + /// See [ObjectBoxRecovery.maxZoom]. static final maxZoom = obx.QueryIntegerProperty(_entities[0].properties[5]); - /// see [ObjectBoxRecovery.startTile] + /// See [ObjectBoxRecovery.startTile]. static final startTile = obx.QueryIntegerProperty(_entities[0].properties[6]); - /// see [ObjectBoxRecovery.endTile] + /// See [ObjectBoxRecovery.endTile]. static final endTile = obx.QueryIntegerProperty(_entities[0].properties[7]); - /// see [ObjectBoxRecovery.typeId] + /// See [ObjectBoxRecovery.typeId]. static final typeId = obx.QueryIntegerProperty(_entities[0].properties[8]); - /// see [ObjectBoxRecovery.rectNwLat] + /// See [ObjectBoxRecovery.rectNwLat]. static final rectNwLat = obx.QueryDoubleProperty(_entities[0].properties[9]); - /// see [ObjectBoxRecovery.rectNwLng] + /// See [ObjectBoxRecovery.rectNwLng]. static final rectNwLng = obx.QueryDoubleProperty(_entities[0].properties[10]); - /// see [ObjectBoxRecovery.rectSeLat] + /// See [ObjectBoxRecovery.rectSeLat]. static final rectSeLat = obx.QueryDoubleProperty(_entities[0].properties[11]); - /// see [ObjectBoxRecovery.rectSeLng] + /// See [ObjectBoxRecovery.rectSeLng]. static final rectSeLng = obx.QueryDoubleProperty(_entities[0].properties[12]); - /// see [ObjectBoxRecovery.circleCenterLat] + /// See [ObjectBoxRecovery.circleCenterLat]. static final circleCenterLat = obx.QueryDoubleProperty(_entities[0].properties[13]); - /// see [ObjectBoxRecovery.circleCenterLng] + /// See [ObjectBoxRecovery.circleCenterLng]. static final circleCenterLng = obx.QueryDoubleProperty(_entities[0].properties[14]); - /// see [ObjectBoxRecovery.circleRadius] + /// See [ObjectBoxRecovery.circleRadius]. static final circleRadius = obx.QueryDoubleProperty(_entities[0].properties[15]); - /// see [ObjectBoxRecovery.lineLats] + /// See [ObjectBoxRecovery.lineLats]. static final lineLats = obx.QueryDoubleVectorProperty( _entities[0].properties[16]); - /// see [ObjectBoxRecovery.lineLngs] + /// See [ObjectBoxRecovery.lineLngs]. static final lineLngs = obx.QueryDoubleVectorProperty( _entities[0].properties[17]); - /// see [ObjectBoxRecovery.lineRadius] + /// See [ObjectBoxRecovery.lineRadius]. static final lineRadius = obx.QueryDoubleProperty(_entities[0].properties[18]); - /// see [ObjectBoxRecovery.customPolygonLats] + /// See [ObjectBoxRecovery.customPolygonLats]. static final customPolygonLats = obx.QueryDoubleVectorProperty( _entities[0].properties[19]); - /// see [ObjectBoxRecovery.customPolygonLngs] + /// See [ObjectBoxRecovery.customPolygonLngs]. static final customPolygonLngs = obx.QueryDoubleVectorProperty( _entities[0].properties[20]); @@ -640,54 +639,54 @@ class ObjectBoxRecovery_ { /// [ObjectBoxStore] entity fields to define ObjectBox queries. class ObjectBoxStore_ { - /// see [ObjectBoxStore.id] + /// See [ObjectBoxStore.id]. static final id = obx.QueryIntegerProperty(_entities[1].properties[0]); - /// see [ObjectBoxStore.name] + /// See [ObjectBoxStore.name]. static final name = obx.QueryStringProperty(_entities[1].properties[1]); - /// see [ObjectBoxStore.length] + /// See [ObjectBoxStore.length]. static final length = obx.QueryIntegerProperty(_entities[1].properties[2]); - /// see [ObjectBoxStore.size] + /// See [ObjectBoxStore.size]. static final size = obx.QueryIntegerProperty(_entities[1].properties[3]); - /// see [ObjectBoxStore.hits] + /// See [ObjectBoxStore.hits]. static final hits = obx.QueryIntegerProperty(_entities[1].properties[4]); - /// see [ObjectBoxStore.misses] + /// See [ObjectBoxStore.misses]. static final misses = obx.QueryIntegerProperty(_entities[1].properties[5]); - /// see [ObjectBoxStore.metadataJson] + /// See [ObjectBoxStore.metadataJson]. static final metadataJson = obx.QueryStringProperty(_entities[1].properties[6]); - /// see [ObjectBoxStore.maxLength] + /// See [ObjectBoxStore.maxLength]. static final maxLength = obx.QueryIntegerProperty(_entities[1].properties[7]); } /// [ObjectBoxTile] entity fields to define ObjectBox queries. class ObjectBoxTile_ { - /// see [ObjectBoxTile.id] + /// See [ObjectBoxTile.id]. static final id = obx.QueryIntegerProperty(_entities[2].properties[0]); - /// see [ObjectBoxTile.url] + /// See [ObjectBoxTile.url]. static final url = obx.QueryStringProperty(_entities[2].properties[1]); - /// see [ObjectBoxTile.bytes] + /// See [ObjectBoxTile.bytes]. static final bytes = obx.QueryByteVectorProperty(_entities[2].properties[2]); - /// see [ObjectBoxTile.lastModified] + /// See [ObjectBoxTile.lastModified]. static final lastModified = obx.QueryDateProperty(_entities[2].properties[3]); @@ -698,15 +697,15 @@ class ObjectBoxTile_ { /// [ObjectBoxRoot] entity fields to define ObjectBox queries. class ObjectBoxRoot_ { - /// see [ObjectBoxRoot.id] + /// See [ObjectBoxRoot.id]. static final id = obx.QueryIntegerProperty(_entities[3].properties[0]); - /// see [ObjectBoxRoot.length] + /// See [ObjectBoxRoot.length]. static final length = obx.QueryIntegerProperty(_entities[3].properties[1]); - /// see [ObjectBoxRoot.size] + /// See [ObjectBoxRoot.size]. static final size = obx.QueryIntegerProperty(_entities[3].properties[2]); } diff --git a/lib/src/bulk_download/internal/tile_loops/shared.dart b/lib/src/bulk_download/internal/tile_loops/shared.dart index 8d20ca78..0aecdd92 100644 --- a/lib/src/bulk_download/internal/tile_loops/shared.dart +++ b/lib/src/bulk_download/internal/tile_loops/shared.dart @@ -1,7 +1,6 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -import 'dart:collection'; import 'dart:isolate'; import 'dart:math'; From 2bb3db63e4f5fe8dc30a109c912f645d627051cd Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 16 Jul 2024 21:53:01 +0100 Subject: [PATCH 22/97] Fixed bug within recovery system initialisation --- lib/src/root/recovery.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/root/recovery.dart b/lib/src/root/recovery.dart index 18cad5f2..21890cf6 100644 --- a/lib/src/root/recovery.dart +++ b/lib/src/root/recovery.dart @@ -37,7 +37,8 @@ part of '../../flutter_map_tile_caching.dart'; /// > Options set at download time, in [StoreDownload.startForeground], are not /// > included. class RootRecovery { - factory RootRecovery._() => _instance ??= const RootRecovery._uninstanced({}); + // ignore: prefer_const_constructors + factory RootRecovery._() => _instance ??= RootRecovery._uninstanced({}); const RootRecovery._uninstanced(Set downloadsOngoing) : _downloadsOngoing = downloadsOngoing; static RootRecovery? _instance; From 9102680f6bf3d72ee0abda56b795d46375270eb7 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 20 Jul 2024 11:40:24 +0100 Subject: [PATCH 23/97] Removed `setMetadata` worker function in favour of `setBulkMetadata` --- .../impls/objectbox/backend/internal.dart | 7 +++-- .../internal_workers/standard/cmd_type.dart | 1 - .../internal_workers/standard/worker.dart | 29 ------------------- 3 files changed, 5 insertions(+), 32 deletions(-) diff --git a/lib/src/backend/impls/objectbox/backend/internal.dart b/lib/src/backend/impls/objectbox/backend/internal.dart index 0d6926d3..9a4bf80b 100644 --- a/lib/src/backend/impls/objectbox/backend/internal.dart +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -516,8 +516,11 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { required String value, }) => _sendCmdOneShot( - type: _CmdType.setMetadata, - args: {'storeName': storeName, 'key': key, 'value': value}, + type: _CmdType.setBulkMetadata, + args: { + 'storeName': storeName, + 'kvs': {key: value}, + }, ); @override diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart index 8fb68691..f2f4b36a 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart @@ -29,7 +29,6 @@ enum _CmdType { removeOldestTilesAboveLimit, removeTilesOlderThan, readMetadata, - setMetadata, setBulkMetadata, removeMetadata, resetMetadata, diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index c063d4d1..bc57c8ad 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -659,35 +659,6 @@ Future _worker( ); query.close(); - case _CmdType.setMetadata: - final storeName = cmd.args['storeName']! as String; - final key = cmd.args['key']! as String; - final value = cmd.args['value']! as String; - - final stores = root.box(); - - final query = - stores.query(ObjectBoxStore_.name.equals(storeName)).build(); - - root.runInTransaction( - TxMode.write, - () { - final store = query.findUnique() ?? - (throw StoreNotExists(storeName: storeName)); - query.close(); - - stores.put( - store - ..metadataJson = jsonEncode( - (jsonDecode(store.metadataJson) as Map) - ..[key] = value, - ), - mode: PutMode.update, - ); - }, - ); - - sendRes(id: cmd.id); case _CmdType.setBulkMetadata: final storeName = cmd.args['storeName']! as String; final kvs = cmd.args['kvs']! as Map; From 3affe10b9da78b4b62bcf62239f9266740415f51 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 22 Jul 2024 23:09:24 +0100 Subject: [PATCH 24/97] Minor documentation improvements --- lib/custom_backend_api.dart | 2 +- lib/src/bulk_download/external/tile_event.dart | 2 +- .../image_provider/internal_get_bytes.dart | 11 ++++------- lib/src/root/statistics.dart | 13 ++++++------- lib/src/store/manage.dart | 15 ++++++++------- 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/lib/custom_backend_api.dart b/lib/custom_backend_api.dart index ff029497..0f5dec5e 100644 --- a/lib/custom_backend_api.dart +++ b/lib/custom_backend_api.dart @@ -8,7 +8,7 @@ /// Many of the methods available through this import are exported and visible /// via the more friendly interface of the main import and function set. /// -/// > [!CAUTION] +/// > [!WARNING] /// > Use this import/library with caution! Assistance with non-typical usecases /// > may be limited. Always use the standard import unless necessary. /// diff --git a/lib/src/bulk_download/external/tile_event.dart b/lib/src/bulk_download/external/tile_event.dart index fbaf6558..7404123f 100644 --- a/lib/src/bulk_download/external/tile_event.dart +++ b/lib/src/bulk_download/external/tile_event.dart @@ -65,7 +65,7 @@ enum TileEventResult { /// [DownloadProgress]' responsibility. /// /// {@template fmtc.tileevent.extraConsiderations} -/// > [!TIP] +/// > [!IMPORTANT] /// > When tracking [TileEvent]s across multiple [DownloadProgress] events, /// > extra considerations are necessary. See /// > [the documentation](https://fmtc.jaffaketchup.dev/bulk-downloading/start#keeping-track-across-events) diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index ff115d70..89d38924 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -252,18 +252,15 @@ Future _internalGetBytes({ bytes: responseBytes, // ignore: unawaited_futures )..then((result) { - final createdIn = result.entries - .where((e) => e.value) - .map((e) => e.key) - .toList(growable: false); + final createdIn = + result.entries.where((e) => e.value).map((e) => e.key); // Clear out old tiles if the maximum store length has been exceeded // We only need to even attempt this if the number of tiles has changed if (createdIn.isEmpty) return; - unawaited( - FMTCBackendAccess.internal - .removeOldestTilesAboveLimit(storeNames: createdIn), + FMTCBackendAccess.internal.removeOldestTilesAboveLimit( + storeNames: createdIn.toList(growable: false), // TODO: Verify ); }); } else { diff --git a/lib/src/root/statistics.dart b/lib/src/root/statistics.dart index fb668bb8..786da3af 100644 --- a/lib/src/root/statistics.dart +++ b/lib/src/root/statistics.dart @@ -8,19 +8,18 @@ class RootStats { const RootStats._(); /// {@macro fmtc.backend.listStores} - Future> get storesAvailable async => - FMTCBackendAccess.internal - .listStores() - .then((s) => s.map(FMTCStore.new).toList()); + Future> get storesAvailable => FMTCBackendAccess.internal + .listStores() + .then((s) => s.map(FMTCStore.new).toList()); /// {@macro fmtc.backend.realSize} - Future get realSize async => FMTCBackendAccess.internal.realSize(); + Future get realSize => FMTCBackendAccess.internal.realSize(); /// {@macro fmtc.backend.rootSize} - Future get size async => FMTCBackendAccess.internal.rootSize(); + Future get size => FMTCBackendAccess.internal.rootSize(); /// {@macro fmtc.backend.rootLength} - Future get length async => FMTCBackendAccess.internal.rootLength(); + Future get length => FMTCBackendAccess.internal.rootLength(); /// {@macro fmtc.backend.watchRecovery} Stream watchRecovery({ diff --git a/lib/src/store/manage.dart b/lib/src/store/manage.dart index 4b671d39..b9274cbe 100644 --- a/lib/src/store/manage.dart +++ b/lib/src/store/manage.dart @@ -13,6 +13,10 @@ class StoreManagement { StoreManagement._(this._storeName); final String _storeName; + /// {@macro fmtc.backend.storeExists} + Future get ready => + FMTCBackendAccess.internal.storeExists(storeName: _storeName); + /// {@macro fmtc.backend.storeGetMaxLength} Future get maxLength => FMTCBackendAccess.internal.storeGetMaxLength(storeName: _storeName); @@ -24,10 +28,6 @@ class StoreManagement { newMaxLength: newMaxLength, ); - /// {@macro fmtc.backend.storeExists} - Future get ready => - FMTCBackendAccess.internal.storeExists(storeName: _storeName); - /// {@macro fmtc.backend.createStore} Future create({int? maxLength}) => FMTCBackendAccess.internal.createStore( @@ -45,9 +45,10 @@ class StoreManagement { /// {@macro fmtc.backend.renameStore} /// - /// The old [FMTCStore] will still retain it's link to the old store, so - /// always use the new returned value instead: returns a new [FMTCStore] - /// after a successful renaming operation. + /// > [!IMPORTANT] + /// > The old [FMTCStore] will still retain it's link to the old store, so + /// > always use the new returned value instead: returns a new [FMTCStore] + /// > after a successful renaming operation. Future rename(String newStoreName) async { await FMTCBackendAccess.internal.renameStore( currentStoreName: _storeName, From 73d1d1ebd14f16e2e77aba0cc647608f57b92894 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 24 Jul 2024 13:46:55 +0100 Subject: [PATCH 25/97] Improved example application --- example/lib/main.dart | 1 - .../forms/bottom_sheet/bottom_sheet.dart | 1 - .../bottom_sheet/components/contents.dart | 18 +- .../home/config_view/forms/side/side.dart | 11 - .../home/config_view/panels/map/map.dart | 56 ++- .../panels/stores/components/list.dart | 17 +- .../stores/components/new_store_button.dart | 54 ++- .../panels/stores/components/no_stores.dart | 12 + .../store_read_write_behaviour_selector.dart | 120 ++++++ .../panels/stores/components/store_tile.dart | 293 ++++++++----- .../config_view/panels/stores/stores.dart | 102 +++-- .../components/debugging_tile_builder.dart | 166 ++++---- .../src/screens/home/map_view/map_view.dart | 395 +++++++++--------- .../src/shared/components/url_selector.dart | 20 + .../internal_store_read_write_behaviour.dart | 56 +++ .../src/shared/misc/store_metadata_keys.dart | 3 +- .../src/shared/state/general_provider.dart | 48 +-- pubspec.yaml | 5 +- 18 files changed, 881 insertions(+), 497 deletions(-) create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart create mode 100644 example/lib/src/shared/misc/internal_store_read_write_behaviour.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index c18a0006..ca9ece0e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -29,7 +29,6 @@ void main() async { kvs: { StoreMetadataKeys.urlTemplate.key: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - StoreMetadataKeys.behaviour.key: CacheBehavior.cacheFirst.name, }, ); diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart index d62717dd..38500690 100644 --- a/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; -import '../../panels/behaviour/behaviour.dart'; import '../../panels/map/map.dart'; import '../../panels/stores/stores.dart'; import 'components/scrollable_provider.dart'; diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart index 8f66dc8e..aa04bab2 100644 --- a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart @@ -51,16 +51,16 @@ class _ContentPanelsState extends State<_ContentPanels> { bottomSheetOuterController: widget.bottomSheetOuterController, ), const SliverToBoxAdapter(child: SizedBox(height: 6)), - const SliverPadding( - padding: EdgeInsets.symmetric(horizontal: 16), - sliver: SliverToBoxAdapter(child: ConfigPanelBehaviour()), - ), - const SliverToBoxAdapter(child: Divider()), - const SliverPadding( - padding: EdgeInsets.symmetric(horizontal: 16), - sliver: SliverToBoxAdapter(child: ConfigPanelMap()), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: ConfigPanelMap( + bottomSheetOuterController: widget.bottomSheetOuterController, + ), + ), ), - const SliverToBoxAdapter(child: Divider()), + const SliverToBoxAdapter(child: Divider(height: 24)), + const SliverToBoxAdapter(child: SizedBox(height: 6)), const ConfigPanelStoresSliver(), ], ); diff --git a/example/lib/src/screens/home/config_view/forms/side/side.dart b/example/lib/src/screens/home/config_view/forms/side/side.dart index e0e0445b..108c1e83 100644 --- a/example/lib/src/screens/home/config_view/forms/side/side.dart +++ b/example/lib/src/screens/home/config_view/forms/side/side.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import '../../panels/behaviour/behaviour.dart'; import '../../panels/map/map.dart'; import '../../panels/stores/stores.dart'; @@ -64,16 +63,6 @@ class _ContentPanels extends StatelessWidget { ), ), const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.surface, - ), - padding: const EdgeInsets.all(16), - width: double.infinity, - child: const ConfigPanelBehaviour(), - ), - const SizedBox(height: 16), Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), diff --git a/example/lib/src/screens/home/config_view/panels/map/map.dart b/example/lib/src/screens/home/config_view/panels/map/map.dart index f68bb13d..fb206936 100644 --- a/example/lib/src/screens/home/config_view/panels/map/map.dart +++ b/example/lib/src/screens/home/config_view/panels/map/map.dart @@ -3,22 +3,76 @@ import 'package:provider/provider.dart'; import '../../../../../shared/components/url_selector.dart'; import '../../../../../shared/state/general_provider.dart'; +import '../../forms/bottom_sheet/components/scrollable_provider.dart'; +import '../behaviour/behaviour.dart'; -class ConfigPanelMap extends StatelessWidget { +class ConfigPanelMap extends StatefulWidget { const ConfigPanelMap({ super.key, + this.bottomSheetOuterController, }); + final DraggableScrollableController? bottomSheetOuterController; + + @override + State createState() => _ConfigPanelMapState(); +} + +class _ConfigPanelMapState extends State { + double? _previousBottomSheetOuterHeight; + double? _previousBottomSheetInnerHeight; + @override Widget build(BuildContext context) => Column( mainAxisSize: MainAxisSize.min, children: [ + const ConfigPanelBehaviour(), + const SizedBox(height: 8), URLSelector( initialValue: context.select( (provider) => provider.urlTemplate, ), onSelected: (urlTemplate) => context.read().urlTemplate = urlTemplate, + onFocus: widget.bottomSheetOuterController != null + ? () { + _previousBottomSheetOuterHeight = + widget.bottomSheetOuterController!.size; + _previousBottomSheetInnerHeight = + BottomSheetScrollableProvider.innerScrollControllerOf( + context, + ).offset; + + widget.bottomSheetOuterController!.animateTo( + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + BottomSheetScrollableProvider.innerScrollControllerOf( + context, + ).animateTo( + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + } + : null, + onUnfocus: widget.bottomSheetOuterController != null + ? () { + widget.bottomSheetOuterController!.animateTo( + _previousBottomSheetOuterHeight ?? 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + BottomSheetScrollableProvider.innerScrollControllerOf( + context, + ).animateTo( + _previousBottomSheetInnerHeight ?? 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + } + : null, ), const SizedBox(height: 6), Selector( diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/list.dart b/example/lib/src/screens/home/config_view/panels/stores/components/list.dart index 10c2d610..3c7d8d9b 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/list.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/list.dart @@ -24,7 +24,7 @@ class _StoresListState extends State { store: ( stats: store.stats.all, metadata: store.metadata.read, - tileImage: store.stats.tileImage(size: 56), + tileImage: store.stats.tileImage(size: 51.2, fit: BoxFit.cover), ), }; }, @@ -47,15 +47,15 @@ class _StoresListState extends State { if (stores.isEmpty) return const NoStores(); - return SliverList.builder( + return SliverList.separated( itemCount: stores.length + 1, itemBuilder: (context, index) { - if (index == 0) return const NewStoreButton(); + if (index == stores.length) return const NewStoreButton(); - final store = stores.keys.elementAt(index - 1); - final stats = stores.values.elementAt(index - 1).stats; - final metadata = stores.values.elementAt(index - 1).metadata; - final tileImage = stores.values.elementAt(index - 1).tileImage; + final store = stores.keys.elementAt(index); + final stats = stores.values.elementAt(index).stats; + final metadata = stores.values.elementAt(index).metadata; + final tileImage = stores.values.elementAt(index).tileImage; return StoreTile( store: store, @@ -64,6 +64,9 @@ class _StoresListState extends State { tileImage: tileImage, ); }, + separatorBuilder: (context, index) => index == stores.length - 1 + ? const Divider() + : const SizedBox.shrink(), ); }, ); diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart b/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart index 10e07159..0bbd8975 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart @@ -8,28 +8,42 @@ class NewStoreButton extends StatelessWidget { @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: SizedBox( - height: double.infinity, - child: FilledButton.tonalIcon( - label: const Text('Create new store'), - icon: const Icon(Icons.create_new_folder), - onPressed: () => - Navigator.of(context).pushNamed(StoreEditorPopup.route), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: SizedBox( + height: double.infinity, + child: FilledButton.tonalIcon( + label: const Text('Create new store'), + icon: const Icon(Icons.create_new_folder), + onPressed: () => Navigator.of(context) + .pushNamed(StoreEditorPopup.route), + ), + ), ), - ), - ), - const SizedBox(width: 8), - IconButton.outlined( - icon: const Icon(Icons.file_open), - tooltip: 'Import store', - onPressed: () {}, + const SizedBox(width: 8), + IconButton.outlined( + icon: const Icon(Icons.file_open), + tooltip: 'Import store', + onPressed: () {}, + ), + ], ), - ], - ), + ), + const SizedBox(height: 24), + Text( + 'Within the example app, for simplicity, each store contains ' + 'tiles from a single URL template. This is not a limitation ' + 'with FMTC.\nAdditionally, FMTC supports changing the ' + 'read/write behaviour for all unspecified stores, but this ' + 'is not represented wihtin this app.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall, + ), + ], ), ); } diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart b/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart index 5b67da42..55db7559 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart @@ -30,6 +30,7 @@ class NoStores extends StatelessWidget { ), const SizedBox(height: 12), SizedBox( + height: 42, width: double.infinity, child: FilledButton.icon( onPressed: () => @@ -40,6 +41,7 @@ class NoStores extends StatelessWidget { ), const SizedBox(height: 6), SizedBox( + height: 42, width: double.infinity, child: OutlinedButton.icon( onPressed: () {}, @@ -47,6 +49,16 @@ class NoStores extends StatelessWidget { label: const Text('Import a store'), ), ), + const SizedBox(height: 32), + Text( + 'Within the example app, for simplicity, each store contains ' + 'tiles from a single URL template. This is not a limitation ' + 'with FMTC.\nAdditionally, FMTC supports changing the ' + 'read/write behaviour for all unspecified stores, but this ' + 'is not represented wihtin this app.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall, + ), ], ), ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart new file mode 100644 index 00000000..b6c46598 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; +import '../../../../../../shared/state/general_provider.dart'; + +class StoreReadWriteBehaviourSelector extends StatelessWidget { + const StoreReadWriteBehaviourSelector({ + super.key, + required this.storeName, + required this.enabled, + }); + + final String storeName; + final bool enabled; + + @override + Widget build(BuildContext context) => + Selector( + selector: (context, provider) => provider.currentStores[storeName], + builder: (context, currentBehaviour, child) => + Selector( + selector: (context, provider) => + provider.inheritableStoreReadWriteBehaviour, + builder: (context, inheritableBehaviour, _) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox.adaptive( + value: currentBehaviour == + InternalStoreReadWriteBehaviour.inherit || + currentBehaviour == null, + onChanged: enabled + ? (v) { + final provider = context.read(); + + provider + ..currentStores[storeName] = v! + ? InternalStoreReadWriteBehaviour.inherit + : InternalStoreReadWriteBehaviour + .fromStoreReadWriteBehavior( + provider.inheritableStoreReadWriteBehaviour, + ) + ..changedCurrentStores(); + } + : null, + materialTapTargetSize: MaterialTapTargetSize.padded, + ), + const VerticalDivider(width: 2), + ...StoreReadWriteBehavior.values.map( + (e) => _StoreReadWriteBehaviourSelectorCheckbox( + storeName: storeName, + representativeBehaviour: e, + currentBehaviour: currentBehaviour == null + ? inheritableBehaviour + : currentBehaviour + .toStoreReadWriteBehavior(inheritableBehaviour), + enabled: enabled, + ), + ), + ], + ), + ), + ); +} + +class _StoreReadWriteBehaviourSelectorCheckbox extends StatelessWidget { + const _StoreReadWriteBehaviourSelectorCheckbox({ + required this.storeName, + required this.representativeBehaviour, + required this.currentBehaviour, + required this.enabled, + }); + + final String storeName; + final StoreReadWriteBehaviour representativeBehaviour; + final StoreReadWriteBehaviour? currentBehaviour; + final bool enabled; + + @override + Widget build(BuildContext context) => Checkbox.adaptive( + value: currentBehaviour == representativeBehaviour + ? true + : InternalStoreReadWriteBehaviour.priority + .indexOf(currentBehaviour) < + InternalStoreReadWriteBehaviour.priority + .indexOf(representativeBehaviour) + ? false + : null, + onChanged: enabled + ? (v) { + final provider = context.read(); + + if (v == null) { + // Deselected current selection + // > Disable inheritance and disable store + provider.currentStores[storeName] = + InternalStoreReadWriteBehaviour.disable; + } else if (representativeBehaviour == + provider.inheritableStoreReadWriteBehaviour) { + // Selected same as inherited + // > Automatically enable inheritance (assumed desire, can be undone) + provider.currentStores[storeName] = + InternalStoreReadWriteBehaviour.inherit; + } else { + // Selected something else + // > Disable inheritance and change store + provider.currentStores[storeName] = + InternalStoreReadWriteBehaviour + .fromStoreReadWriteBehavior( + representativeBehaviour, + ); + } + provider.changedCurrentStores(); + } + : null, + tristate: true, + materialTapTargetSize: MaterialTapTargetSize.padded, + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart index 25639525..b3c36e90 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; @@ -6,8 +8,9 @@ import '../../../../../../shared/misc/exts/size_formatter.dart'; import '../../../../../../shared/misc/store_metadata_keys.dart'; import '../../../../../../shared/state/general_provider.dart'; import '../../../../../store_editor/store_editor.dart'; +import 'store_read_write_behaviour_selector.dart'; -class StoreTile extends StatelessWidget { +class StoreTile extends StatefulWidget { const StoreTile({ super.key, required this.store, @@ -21,129 +24,213 @@ class StoreTile extends StatelessWidget { final Future> metadata; final Future tileImage; + @override + State createState() => _StoreTileState(); +} + +class _StoreTileState extends State { + bool _toolsVisible = false; + bool _toolsEmptyLoading = false; + bool _toolsDeleteLoading = false; + Timer? _toolsAutoHiderTimer; + @override Widget build(BuildContext context) => Material( color: Colors.transparent, child: Consumer( - builder: (context, provider, _) { - final isSelected = - provider.currentStores.contains(store.storeName) && - provider.storesSelectionMode == false; - - return FutureBuilder( - future: metadata, - builder: (context, metadataSnapshot) { - final matchesUrl = metadataSnapshot.data == null - ? null - : provider.urlTemplate == - metadataSnapshot - .data![StoreMetadataKeys.urlTemplate.key]; + builder: (context, provider, _) => FutureBuilder( + future: widget.metadata, + builder: (context, metadataSnapshot) { + final matchesUrl = metadataSnapshot.data != null && + provider.urlTemplate == + metadataSnapshot.data![StoreMetadataKeys.urlTemplate.key]; - final inUse = provider.storesSelectionMode != null && - (matchesUrl ?? false) && - (provider.storesSelectionMode! || isSelected); + final toolsChildren = _toolsDeleteLoading + ? [ + const Center( + child: SizedBox.square( + dimension: 25, + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + ), + ] + : [ + IconButton( + onPressed: _editStore, + icon: const Icon(Icons.edit), + ), + if (_toolsEmptyLoading) + const Center( + child: SizedBox.square( + dimension: 25, + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + ) + else + IconButton( + onPressed: _emptyStore, + icon: const Icon(Icons.delete), + ), + IconButton( + onPressed: _deleteStore, + icon: const Icon( + Icons.delete_forever, + color: Colors.red, + ), + ), + ]; - return ListTile( - title: Text(store.storeName), - enabled: (provider.storesSelectionMode ?? true) || - (matchesUrl ?? false), + return InkWell( + onSecondaryTap: _showTools, + child: ListTile( + title: Text( + widget.store.storeName, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + ), subtitle: FutureBuilder( - future: stats, + future: widget.stats, builder: (context, statsSnapshot) { if (statsSnapshot.data case final stats?) { - final statsPart = - '${stats.size.asReadableSize} | ${stats.length} tiles'; - - final usagePart = provider.storesSelectionMode == null - ? '' - : (matchesUrl ?? false) - ? (provider.storesSelectionMode ?? true) || - isSelected - ? '\nIn use' - : '\nNot in use' - : '\nSource mismatch'; - - return Text(statsPart + usagePart); + return Text( + '${(stats.size * 1024).asReadableSize} | ' + '${stats.length} tiles', + ); } - - return const Text('Loading stats...\nLoading usage...'); + return const Text('Loading stats...'); }, ), - leading: SizedBox.square( - dimension: 56, - child: Stack( - fit: StackFit.expand, - alignment: Alignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(16), - child: FutureBuilder( - future: tileImage, - builder: (context, snapshot) { - if (snapshot.data case final data?) return data; - return const Icon(Icons.filter_none); - }, + leading: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: FutureBuilder( + future: widget.tileImage, + builder: (context, snapshot) { + if (snapshot.data case final data?) return data; + return const Icon(Icons.filter_none); + }, + ), + ), + ), + trailing: IntrinsicWidth( + child: IntrinsicHeight( + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(24), + ), + padding: const EdgeInsets.all(4), + child: StoreReadWriteBehaviourSelector( + storeName: widget.store.storeName, + enabled: matchesUrl, + ), ), - ), - Center( - child: SizedBox.square( - dimension: 24, - child: AnimatedOpacity( - opacity: inUse ? 1 : 0, - duration: const Duration(milliseconds: 100), - curve: inUse ? Curves.easeIn : Curves.easeOut, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(99), + AnimatedOpacity( + opacity: matchesUrl ? 0 : 1, + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: matchesUrl, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .error + .withOpacity(0.75), + borderRadius: BorderRadius.circular(16), + ), + child: const Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + Icon(Icons.link_off, color: Colors.white), + Text( + 'URL mismatch', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), ), - child: const Center( - child: Icon( - Icons.check, - color: Colors.white, - size: 20, + ), + ), + ), + AnimatedOpacity( + opacity: _toolsVisible ? 1 : 0, + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: !_toolsVisible, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceDim, + borderRadius: BorderRadius.circular(24), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: toolsChildren, ), ), ), ), ), - ), - ], - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.delete), - onPressed: () {}, - ), - IconButton( - icon: const Icon(Icons.edit), - onPressed: () => Navigator.of(context).pushNamed( - StoreEditorPopup.route, - arguments: store.storeName, - ), + ], ), - ], + ), ), - onTap: provider.storesSelectionMode == false - ? () { - if (isSelected) { - context - .read() - .removeStore(store.storeName); - } else { - context - .read() - .addStore(store.storeName); - } - } - : null, - ); - }, - ); - }, + onLongPress: _showTools, + onTap: _hideTools, + ), + ); + }, + ), ), ); + + Future _editStore() async { + await Navigator.of(context).pushNamed( + StoreEditorPopup.route, + arguments: widget.store.storeName, + ); + await _hideTools(); + } + + Future _emptyStore() async { + _toolsAutoHiderTimer?.cancel(); + setState(() => _toolsEmptyLoading = true); + await widget.store.manage.reset(); + await _hideTools(); + setState(() => _toolsEmptyLoading = false); + } + + Future _deleteStore() async { + _toolsAutoHiderTimer?.cancel(); + setState(() => _toolsDeleteLoading = true); + await widget.store.manage.delete(); + } + + Future _hideTools() async { + setState(() => _toolsVisible = false); + return Future.delayed(const Duration(milliseconds: 110)); + } + + void _showTools() { + setState(() => _toolsVisible = true); + _toolsAutoHiderTimer = Timer(const Duration(seconds: 5), _hideTools); + } } diff --git a/example/lib/src/screens/home/config_view/panels/stores/stores.dart b/example/lib/src/screens/home/config_view/panels/stores/stores.dart index cc24d6df..9c60ac03 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/stores.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/stores.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; +import '../../../../../shared/misc/internal_store_read_write_behaviour.dart'; import '../../../../../shared/state/general_provider.dart'; import 'components/list.dart'; @@ -12,41 +14,89 @@ class ConfigPanelStoresSliver extends StatelessWidget { @override Widget build(BuildContext context) => SliverMainAxisGroup( slivers: [ - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), + const SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 28), sliver: SliverToBoxAdapter( - child: Selector( - selector: (context, provider) => provider.storesSelectionMode, - builder: (context, storesSelectionMode, _) => SegmentedButton( - segments: const [ - ButtonSegment( - value: null, - icon: Icon(Icons.deselect_rounded), - label: Text('Disabled'), + child: IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Tooltip( + message: 'Inherit', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.settings_suggest), + ), + ), + VerticalDivider(width: 2), + Tooltip( + message: 'Read only', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.visibility), + ), ), - ButtonSegment( - value: true, - icon: Icon(Icons.select_all_rounded), - label: Text('Automatic'), + Tooltip( + message: ' + update existing', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.edit), + ), ), - ButtonSegment( - value: false, - icon: Icon(Icons.highlight_alt_rounded), - label: Text('Manual'), + Tooltip( + message: ' + create new', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.add), + ), ), ], - selected: {storesSelectionMode}, - onSelectionChanged: (value) => context - .read() - .storesSelectionMode = value.single, - style: const ButtonStyle( - visualDensity: VisualDensity.comfortable, - ), ), ), ), ), - const SliverToBoxAdapter(child: SizedBox(height: 6)), + const SliverToBoxAdapter(child: SizedBox(height: 8)), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 28), + sliver: SliverToBoxAdapter( + child: Selector( + selector: (context, provider) => + provider.inheritableStoreReadWriteBehaviour, + builder: (context, currentBehaviour, child) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: StoreReadWriteBehavior.values.map( + (e) { + final value = currentBehaviour == e + ? true + : InternalStoreReadWriteBehaviour.priority + .indexOf(currentBehaviour) < + InternalStoreReadWriteBehaviour.priority + .indexOf(e) + ? false + : null; + + return Checkbox.adaptive( + value: value, + onChanged: (v) => context + .read() + .inheritableStoreReadWriteBehaviour = + v == null ? null : e, + tristate: true, + materialTapTargetSize: MaterialTapTargetSize.padded, + ); + }, + ).toList(growable: false), + ), + ), + ), + ), + const SliverToBoxAdapter( + child: Divider( + height: 8, + indent: 24, + endIndent: 24, + ), + ), const StoresList(), const SliverToBoxAdapter(child: SizedBox(height: 16)), ], diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart b/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart index ac4e7289..294f1820 100644 --- a/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart +++ b/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart @@ -8,11 +8,13 @@ class DebuggingTileBuilder extends StatelessWidget { required this.tileWidget, required this.tile, required this.tileLoadingDebugger, + required this.usingFMTC, }); final Widget tileWidget; final TileImage tile; final ValueNotifier tileLoadingDebugger; + final bool usingFMTC; @override Widget build(BuildContext context) => Stack( @@ -30,92 +32,108 @@ class DebuggingTileBuilder extends StatelessWidget { position: DecorationPosition.foreground, child: tileWidget, ), - ValueListenableBuilder( - valueListenable: tileLoadingDebugger, - builder: (context, value, _) { - final info = value[tile.coordinates]; + if (!usingFMTC) + const OverflowBox( + child: Padding( + padding: EdgeInsets.all(6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.disabled_by_default_rounded, + size: 32, + ), + SizedBox(height: 6), + Text('FMTC not in use'), + ], + ), + ), + ) + else + ValueListenableBuilder( + valueListenable: tileLoadingDebugger, + builder: (context, value, _) { + final info = value[tile.coordinates]; - if (info == null) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } + if (info == null) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } - return OverflowBox( - child: Padding( - padding: const EdgeInsets.all(6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'x${tile.coordinates.x} y${tile.coordinates.y} ' - 'z${tile.coordinates.z}', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - if (info.error case final error?) + return OverflowBox( + child: Padding( + padding: const EdgeInsets.all(6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - error is FMTCBrowsingError - ? error.type.name - : 'Unknown error', + 'x${tile.coordinates.x} y${tile.coordinates.y} ' + 'z${tile.coordinates.z}', + style: const TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), - if (info.result case final result?) ...[ - Text( - "'${result.name}' in " - '${tile.loadFinishedAt == null || tile.loadStarted == null ? 'Loading...' : '${tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms'}\n', - textAlign: TextAlign.center, - ), - if (info.existingStores case final existingStores?) + if (info.error case final error?) Text( - "Existed in: '${existingStores.join("', '")}'", + error is FMTCBrowsingError + ? error.type.name + : 'Unknown error', textAlign: TextAlign.center, - ) - else - const Text( - 'New tile', + ), + if (info.result case final result?) ...[ + Text( + "'${result.name}' in " + '${tile.loadFinishedAt == null || tile.loadStarted == null ? 'Loading...' : '${tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms'}\n', textAlign: TextAlign.center, ), - if (info.writeResult case final writeResult?) - FutureBuilder( - future: writeResult, - builder: (context, snapshot) { - if (snapshot.data == null) { - return const Text('Caching tile...'); - } - return TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - visualDensity: VisualDensity.compact, - minimumSize: Size.zero, - ), - onPressed: () { - showDialog( - context: context, - builder: (context) => - _TileWriteResultsDialog( - results: snapshot.data!, + if (info.existingStores case final existingStores?) + Text( + "Existed in: '${existingStores.join("', '")}'", + textAlign: TextAlign.center, + ) + else + const Text( + 'New tile', + textAlign: TextAlign.center, + ), + if (info.writeResult case final writeResult?) + FutureBuilder( + future: writeResult, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const Text('Caching tile...'); + } + return TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, ), - ); - }, - child: const Text('View write result'), - ); - }, - ) - else - const Text('No write necessary'), + visualDensity: VisualDensity.compact, + minimumSize: Size.zero, + ), + onPressed: () { + showDialog( + context: context, + builder: (context) => + _TileWriteResultsDialog( + results: snapshot.data!, + ), + ); + }, + child: const Text('View write result'), + ); + }, + ) + else + const Text('No write necessary'), + ], ], - ], + ), ), - ), - ); - }, - ), + ); + }, + ), ], ); } diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart index 715439b5..a97fa12a 100644 --- a/example/lib/src/screens/home/map_view/map_view.dart +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -1,5 +1,6 @@ import 'dart:math'; +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'; @@ -43,74 +44,27 @@ class MapView extends StatefulWidget { State createState() => _MapViewState(); } -class _MapViewState extends State - with TickerProviderStateMixin, WidgetsBindingObserver { - late final mapController = AnimatedMapController( +class _MapViewState extends State with TickerProviderStateMixin { + late final _mapController = AnimatedMapController( vsync: this, curve: MapView.animationCurve, // ignore: avoid_redundant_argument_values duration: MapView.animationDuration, ); - final tileLoadingDebugger = ValueNotifier({}); + final _tileLoadingDebugger = ValueNotifier({}); - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _setMapLocationCache(); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.paused) { - _setMapLocationCache(); - } - } - - void _setMapLocationCache() { - sharedPrefs - ..setDouble( - SharedPrefsKeys.mapLocationLat.name, - mapController.mapController.camera.center.latitude, - ) - ..setDouble( - SharedPrefsKeys.mapLocationLng.name, - mapController.mapController.camera.center.longitude, - ) - ..setDouble( - SharedPrefsKeys.mapLocationZoom.name, - mapController.mapController.camera.zoom, - ); - } + late final _storesStream = + FMTCRoot.stats.watchStores(triggerImmediately: true).asyncMap( + (_) async { + final stores = await FMTCRoot.stats.storesAvailable; - final _attributionLayer = RichAttributionWidget( - alignment: AttributionAlignment.bottomLeft, - popupInitialDisplayDuration: const Duration(seconds: 3), - popupBorderRadius: BorderRadius.circular(12), - attributions: [ - //TextSourceAttribution(Uri.parse(urlTemplate).host), - const TextSourceAttribution( - 'For demonstration purposes only', - prependCopyright: false, - textStyle: TextStyle(fontWeight: FontWeight.bold), - ), - const TextSourceAttribution( - 'Offline mapping made with FMTC', - prependCopyright: false, - textStyle: TextStyle(fontStyle: FontStyle.italic), - ), - LogoSourceAttribution( - Image.asset('assets/icons/ProjectIcon.png'), - tooltip: 'flutter_map_tile_caching', - ), - ], + return { + for (final store in stores) + store.storeName: await store.metadata.read + .then((e) => e[StoreMetadataKeys.urlTemplate.key]), + }; + }, ); @override @@ -198,7 +152,7 @@ class _MapViewState extends State if (provider.regionType == RegionType.customPolygon) { final coords = provider.coordinates; if (coords.length > 1) { - final newPointPos = mapController.mapController.camera + final newPointPos = _mapController.mapController.camera .latLngToScreenPoint(coords.first) .toOffset(); provider.customPolygonSnap = coords.first != coords.last && @@ -223,10 +177,10 @@ class _MapViewState extends State if (provider.regionType == RegionType.customPolygon) { final coords = provider.coordinates; if (coords.length > 1) { - final newPointPos = mapController.mapController.camera + final newPointPos = _mapController.mapController.camera .latLngToScreenPoint(coords.first) .toOffset(); - final centerPos = mapController.mapController.camera + final centerPos = _mapController.mapController.camera .latLngToScreenPoint(provider.currentNewPointPos) .toOffset(); provider.customPolygonSnap = coords.first != coords.last && @@ -239,164 +193,193 @@ class _MapViewState extends State } } }, - onMapReady: () { - /*context.read() - ..mapController = mapController.mapController - ..animateTo = mapController.animateTo;*/ + onMapEvent: (event) { + if (event is MapEventFlingAnimationNotStarted || + event is MapEventMoveEnd || + event is MapEventFlingAnimationEnd || + event is MapEventScrollWheelZoom) { + sharedPrefs + ..setDouble( + SharedPrefsKeys.mapLocationLat.name, + _mapController.mapController.camera.center.latitude, + ) + ..setDouble( + SharedPrefsKeys.mapLocationLng.name, + _mapController.mapController.camera.center.longitude, + ) + ..setDouble( + SharedPrefsKeys.mapLocationZoom.name, + _mapController.mapController.camera.zoom, + ); + } }, ); - return Selector>( - selector: (context, provider) => provider.currentStores, - builder: (context, currentStores, _) { - final map = FlutterMap( - mapController: mapController.mapController, - options: mapOptions, - children: [ - FutureBuilder?>( - future: /*currentStores.isEmpty - ? Future.sync(() => {}) - : FMTCStore(currentStores.first).metadata.read*/ - const FMTCStore('Test Store').metadata.read, - builder: (context, metadata) { - if (!metadata.hasData || - metadata.data == null || - (currentStores.isNotEmpty && metadata.data!.isEmpty)) { - return const AbsorbPointer( - child: LoadingIndicator('Preparing map'), - ); - } + return StreamBuilder( + stream: _storesStream, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const AbsorbPointer(child: LoadingIndicator('Preparing map')); + } + + final stores = snapshot.data!; - final urlTemplate = - currentStores.isNotEmpty && metadata.data != null - ? metadata.data![StoreMetadataKeys.urlTemplate.key]! - : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + return Consumer( + builder: (context, provider, _) { + final urlTemplate = provider.urlTemplate; - return TileLayer( + final compiledStoreNames = Map.fromEntries( + stores.entries + .where((e) => e.value == urlTemplate) + .map((e) => e.key) + .map((e) { + final internalBehaviour = provider.currentStores[e]; + final behaviour = internalBehaviour == null + ? provider.inheritableStoreReadWriteBehaviour + : internalBehaviour.toStoreReadWriteBehavior( + provider.inheritableStoreReadWriteBehaviour, + ); + if (behaviour == null) return null; + return MapEntry(e, behaviour); + }).whereNotNull(), + ); + print(compiledStoreNames); + + final attribution = RichAttributionWidget( + alignment: AttributionAlignment.bottomLeft, + popupInitialDisplayDuration: const Duration(seconds: 3), + popupBorderRadius: BorderRadius.circular(12), + attributions: [ + TextSourceAttribution(Uri.parse(urlTemplate).host), + const TextSourceAttribution( + 'For demonstration purposes only', + prependCopyright: false, + textStyle: TextStyle(fontWeight: FontWeight.bold), + ), + const TextSourceAttribution( + 'Offline mapping made with FMTC', + prependCopyright: false, + textStyle: TextStyle(fontStyle: FontStyle.italic), + ), + LogoSourceAttribution( + Image.asset('assets/icons/ProjectIcon.png'), + tooltip: 'flutter_map_tile_caching', + ), + ], + ); + + final map = FlutterMap( + mapController: _mapController.mapController, + options: mapOptions, + children: [ + TileLayer( urlTemplate: urlTemplate, userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', maxNativeZoom: 20, - tileProvider: const FMTCStore('Test Store').getTileProvider( - settings: FMTCTileProviderSettings( - behavior: CacheBehavior.values.byName( - metadata.data![StoreMetadataKeys.behaviour.key]!, - ), - ), - tileLoadingDebugger: tileLoadingDebugger, - ), - /*currentStores.isNotEmpty - ? FMTCStore(currentStores.first).getTileProvider( + tileProvider: compiledStoreNames.isEmpty + ? NetworkTileProvider() + : FMTCTileProvider.multipleStores( + storeNames: compiledStoreNames, settings: FMTCTileProviderSettings( - behavior: CacheBehavior.values - .byName(metadata.data!['behaviour']!), - cachedValidDuration: int.parse( - metadata.data!['validDuration']!, - ) == - 0 - ? Duration.zero - : Duration( - days: int.parse( - metadata.data!['validDuration']!, - ), - ), - /*maxStoreLength: - int.parse(metadata.data!['maxLength']!),*/ + behavior: provider.cacheBehavior, + recordHitsAndMisses: false, + ), + tileLoadingDebugger: _tileLoadingDebugger, + ), + tileBuilder: !provider.displayDebugOverlay + ? null + : (context, tileWidget, tile) => DebuggingTileBuilder( + tileLoadingDebugger: _tileLoadingDebugger, + tileWidget: tileWidget, + tile: tile, + usingFMTC: compiledStoreNames.isNotEmpty, ), - tileLoadingDebugger: tileLoadingDebugger, - ) - : NetworkTileProvider(),*/ - tileBuilder: (context, tileWidget, tile) => - DebuggingTileBuilder( - tileLoadingDebugger: tileLoadingDebugger, - tileWidget: tileWidget, - tile: tile, - ), - ); - }, - ), - if (widget.mode == MapViewMode.regionSelect) ...[ - const RegionShape(), - const CustomPolygonSnappingIndicator(), - ], - if (widget.bottomPaddingWrapperBuilder != null) - Builder( - builder: (context) => widget.bottomPaddingWrapperBuilder!( - context, - _attributionLayer, ), - ) - else - _attributionLayer, - ], - ); + if (widget.mode == MapViewMode.regionSelect) ...[ + const RegionShape(), + const CustomPolygonSnappingIndicator(), + ], + if (widget.bottomPaddingWrapperBuilder != null) + Builder( + builder: (context) => widget.bottomPaddingWrapperBuilder!( + context, + attribution, + ), + ) + else + attribution, + ], + ); - return LayoutBuilder( - builder: (context, constraints) { - final double sidePanelLeft = - switch ((widget.layoutDirection, widget.mode)) { - (Axis.vertical, _) => 0, - (Axis.horizontal, MapViewMode.regionSelect) => 0, - (Axis.horizontal, MapViewMode.standard) => -85, - }; - final double sidePanelBottom = - switch ((widget.layoutDirection, widget.mode)) { - (Axis.horizontal, _) => 0, - (Axis.vertical, MapViewMode.regionSelect) => 0, - (Axis.vertical, MapViewMode.standard) => -85, - }; + return LayoutBuilder( + builder: (context, constraints) { + final double sidePanelLeft = + switch ((widget.layoutDirection, widget.mode)) { + (Axis.vertical, _) => 0, + (Axis.horizontal, MapViewMode.regionSelect) => 0, + (Axis.horizontal, MapViewMode.standard) => -85, + }; + final double sidePanelBottom = + switch ((widget.layoutDirection, widget.mode)) { + (Axis.horizontal, _) => 0, + (Axis.vertical, MapViewMode.regionSelect) => 0, + (Axis.vertical, MapViewMode.standard) => -85, + }; - return Stack( - fit: StackFit.expand, - children: [ - MouseRegion( - opaque: false, - cursor: widget.mode == MapViewMode.standard || - context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter - ? MouseCursor.defer - : context.select( - (p) => p.customPolygonSnap, - ) - ? SystemMouseCursors.none - : SystemMouseCursors.precise, - child: map, - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - left: sidePanelLeft, - bottom: sidePanelBottom, - child: SizedBox( - height: widget.layoutDirection == Axis.horizontal - ? constraints.maxHeight - : null, - width: widget.layoutDirection == Axis.horizontal - ? null - : constraints.maxWidth, - child: Padding( - padding: const EdgeInsets.all(8), - child: RegionSelectionSidePanel( - layoutDirection: widget.layoutDirection, - bottomPaddingWrapperBuilder: - widget.bottomPaddingWrapperBuilder, + return Stack( + fit: StackFit.expand, + children: [ + MouseRegion( + opaque: false, + cursor: widget.mode == MapViewMode.standard || + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter + ? MouseCursor.defer + : context.select( + (p) => p.customPolygonSnap, + ) + ? SystemMouseCursors.none + : SystemMouseCursors.precise, + child: map, + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + left: sidePanelLeft, + bottom: sidePanelBottom, + child: SizedBox( + height: widget.layoutDirection == Axis.horizontal + ? constraints.maxHeight + : null, + width: widget.layoutDirection == Axis.horizontal + ? null + : constraints.maxWidth, + child: Padding( + padding: const EdgeInsets.all(8), + child: RegionSelectionSidePanel( + layoutDirection: widget.layoutDirection, + bottomPaddingWrapperBuilder: + widget.bottomPaddingWrapperBuilder, + ), + ), ), ), - ), - ), - if (widget.mode == MapViewMode.regionSelect && - context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter && - !context.select( - (p) => p.customPolygonSnap, - )) - const Center(child: Crosshairs()), - ], + if (widget.mode == MapViewMode.regionSelect && + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter && + !context.select( + (p) => p.customPolygonSnap, + )) + const Center(child: Crosshairs()), + ], + ); + }, ); }, ); diff --git a/example/lib/src/shared/components/url_selector.dart b/example/lib/src/shared/components/url_selector.dart index 3d38001e..1d4f5fa0 100644 --- a/example/lib/src/shared/components/url_selector.dart +++ b/example/lib/src/shared/components/url_selector.dart @@ -14,11 +14,15 @@ class URLSelector extends StatefulWidget { this.initialValue, this.onSelected, this.helperText, + this.onFocus, + this.onUnfocus, }); final String? initialValue; final void Function(String)? onSelected; final String? helperText; + final void Function()? onFocus; + final void Function()? onUnfocus; @override State createState() => _URLSelectorState(); @@ -46,19 +50,33 @@ class _URLSelectorState extends State { Map> enableButtonEvaluatorMap = {}; final enableAddUrlButton = ValueNotifier(false); + late final dropdownMenuFocusNode = + widget.onFocus != null || widget.onUnfocus != null ? FocusNode() : null; + @override void initState() { super.initState(); urlTextController.addListener(_urlTextControllerListener); + dropdownMenuFocusNode?.addListener(_dropdownMenuFocusListener); } @override void dispose() { 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); @@ -103,6 +121,7 @@ class _URLSelectorState extends State { onSelected: _onSelected, helperText: 'Use standard placeholders & include protocol' '${widget.helperText != null ? '\n${widget.helperText}' : ''}', + focusNode: dropdownMenuFocusNode, ), ), Padding( @@ -136,6 +155,7 @@ class _URLSelectorState extends State { } widget.onSelected!(v ?? urlTextController.text); + dropdownMenuFocusNode?.unfocus(); } Future>> _constructTemplatesToStoresStream( diff --git a/example/lib/src/shared/misc/internal_store_read_write_behaviour.dart b/example/lib/src/shared/misc/internal_store_read_write_behaviour.dart new file mode 100644 index 00000000..61df845c --- /dev/null +++ b/example/lib/src/shared/misc/internal_store_read_write_behaviour.dart @@ -0,0 +1,56 @@ +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +/// Determines the read/update/create tile behaviour of a store +/// +/// Expands [StoreReadWriteBehaviour]. +enum InternalStoreReadWriteBehaviour { + /// Disable store entirely + disable, + + /// Inherit from general setting + inherit, + + /// Only read tiles + read, + + /// Read tiles, and also update existing tiles + /// + /// Unlike 'create', if (an older version of) a tile does not already exist in + /// the store, it will not be written. + readUpdate, + + /// Read, update, and create tiles + /// + /// See [readUpdate] for a definition of 'update'. + readUpdateCreate; + + StoreReadWriteBehavior? toStoreReadWriteBehavior([ + StoreReadWriteBehavior? inheritableBehaviour, + ]) => + switch (this) { + disable => null, + inherit => inheritableBehaviour, + read => StoreReadWriteBehavior.read, + readUpdate => StoreReadWriteBehavior.readUpdate, + readUpdateCreate => StoreReadWriteBehavior.readUpdateCreate, + }; + + static InternalStoreReadWriteBehaviour fromStoreReadWriteBehavior( + StoreReadWriteBehavior? behaviour, + ) => + switch (behaviour) { + null => InternalStoreReadWriteBehaviour.disable, + StoreReadWriteBehavior.read => InternalStoreReadWriteBehaviour.read, + StoreReadWriteBehavior.readUpdate => + InternalStoreReadWriteBehaviour.readUpdate, + StoreReadWriteBehavior.readUpdateCreate => + InternalStoreReadWriteBehaviour.readUpdateCreate, + }; + + static const priority = [ + null, + StoreReadWriteBehaviour.read, + StoreReadWriteBehaviour.readUpdate, + StoreReadWriteBehaviour.readUpdateCreate, + ]; +} diff --git a/example/lib/src/shared/misc/store_metadata_keys.dart b/example/lib/src/shared/misc/store_metadata_keys.dart index 655d389a..ad2b2595 100644 --- a/example/lib/src/shared/misc/store_metadata_keys.dart +++ b/example/lib/src/shared/misc/store_metadata_keys.dart @@ -1,6 +1,5 @@ enum StoreMetadataKeys { - urlTemplate('sourceURL'), - behaviour('behaviour'); + urlTemplate('sourceURL'); const StoreMetadataKeys(this.key); final String key; diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart index f52b8f90..40423874 100644 --- a/example/lib/src/shared/state/general_provider.dart +++ b/example/lib/src/shared/state/general_provider.dart @@ -1,16 +1,23 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import '../misc/internal_store_read_write_behaviour.dart'; + class GeneralProvider extends ChangeNotifier { - Set _currentStores = {}; - Set get currentStores => _currentStores; - set currentStores(Set newStores) { - _currentStores = newStores; + StoreReadWriteBehaviour? _inheritableStoreReadWriteBehaviour = + StoreReadWriteBehaviour.readUpdateCreate; + StoreReadWriteBehaviour? get inheritableStoreReadWriteBehaviour => + _inheritableStoreReadWriteBehaviour; + set inheritableStoreReadWriteBehaviour( + StoreReadWriteBehaviour? newBehaviour, + ) { + _inheritableStoreReadWriteBehaviour = newBehaviour; notifyListeners(); } + final Map currentStores = {}; + void changedCurrentStores() => notifyListeners(); + String _urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; String get urlTemplate => _urlTemplate; set urlTemplate(String newUrlTemplate) { @@ -18,44 +25,17 @@ class GeneralProvider extends ChangeNotifier { notifyListeners(); } - void removeStore(String store) { - _currentStores.remove(store); - notifyListeners(); - } - - void addStore(String store) { - _currentStores.add(store); - notifyListeners(); - } - - CacheBehavior _cacheBehavior = CacheBehavior.onlineFirst; + CacheBehavior _cacheBehavior = CacheBehavior.cacheFirst; CacheBehavior get cacheBehavior => _cacheBehavior; set cacheBehavior(CacheBehavior newCacheBehavior) { _cacheBehavior = newCacheBehavior; notifyListeners(); } - /*bool _behaviourUpdateFromNetwork = true; - bool get behaviourUpdateFromNetwork => _behaviourUpdateFromNetwork; - set behaviourUpdateFromNetwork(bool newBehaviourUpdateFromNetwork) { - _behaviourUpdateFromNetwork = newBehaviourUpdateFromNetwork; - notifyListeners(); - }*/ - bool _displayDebugOverlay = true; bool get displayDebugOverlay => _displayDebugOverlay; set displayDebugOverlay(bool newDisplayDebugOverlay) { _displayDebugOverlay = newDisplayDebugOverlay; notifyListeners(); } - - bool? _storesSelectionMode = true; - bool? get storesSelectionMode => _storesSelectionMode; - set storesSelectionMode(bool? newSelectionMode) { - _storesSelectionMode = newSelectionMode; - notifyListeners(); - } - - final StreamController resetController = StreamController.broadcast(); - void resetMap() => resetController.add(null); } diff --git a/pubspec.yaml b/pubspec.yaml index b2cbfc2b..6d6ceeea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 10.0.0-dev.2 +version: 10.0.0-dev.3 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues @@ -30,10 +30,11 @@ dependencies: async: ^2.11.0 collection: ^1.18.0 dart_earcut: ^1.1.0 + flat_buffers: ^23.5.26 flutter: sdk: flutter flutter_map: ^7.0.0 - http: ^1.2.1 + http: ^1.2.2 latlong2: ^0.9.1 meta: ^1.12.0 objectbox: ^4.0.1 From 57f680b5e64b1119391f78a94880ad12ffcf6a36 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 24 Jul 2024 13:49:50 +0100 Subject: [PATCH 26/97] Minor example application improvement --- .../config_view/panels/stores/components/store_tile.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart index b3c36e90..db997421 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart @@ -125,7 +125,7 @@ class _StoreTileState extends State { Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.all(4), child: StoreReadWriteBehaviourSelector( @@ -146,7 +146,7 @@ class _StoreTileState extends State { .colorScheme .error .withOpacity(0.75), - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(12), ), child: const Row( mainAxisAlignment: @@ -178,7 +178,7 @@ class _StoreTileState extends State { color: Theme.of(context) .colorScheme .surfaceDim, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisAlignment: From 26211aafdd320591e3c40a05898846dd69146fbc Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 24 Jul 2024 14:52:41 +0100 Subject: [PATCH 27/97] Improved example application --- .../forms/bottom_sheet/bottom_sheet.dart | 2 +- .../bottom_sheet/components/contents.dart | 3 +- .../home/config_view/forms/side/side.dart | 7 +- .../config_view/forms/side_panel/side.dart | 106 ------------------ ...lumn_headers_and_inheritable_settings.dart | 96 ++++++++++++++++ .../store_read_write_behaviour_selector.dart | 2 + .../panels/stores/components/store_tile.dart | 7 +- .../config_view/panels/stores/stores.dart | 104 ----------------- .../list.dart => stores_list.dart} | 26 +++-- .../src/screens/home/map_view/map_view.dart | 1 - .../screens/store_editor/store_editor.dart | 92 ++++++++++++++- 11 files changed, 214 insertions(+), 232 deletions(-) delete mode 100644 example/lib/src/screens/home/config_view/forms/side_panel/side.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart delete mode 100644 example/lib/src/screens/home/config_view/panels/stores/stores.dart rename example/lib/src/screens/home/config_view/panels/stores/{components/list.dart => stores_list.dart} (66%) diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart index 38500690..9b3e1ba8 100644 --- a/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; import '../../panels/map/map.dart'; -import '../../panels/stores/stores.dart'; +import '../../panels/stores/stores_list.dart'; import 'components/scrollable_provider.dart'; import 'components/tab_header.dart'; diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart index aa04bab2..684f7e4c 100644 --- a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart @@ -61,7 +61,8 @@ class _ContentPanelsState extends State<_ContentPanels> { ), const SliverToBoxAdapter(child: Divider(height: 24)), const SliverToBoxAdapter(child: SizedBox(height: 6)), - const ConfigPanelStoresSliver(), + const StoresList(), + const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ); } diff --git a/example/lib/src/screens/home/config_view/forms/side/side.dart b/example/lib/src/screens/home/config_view/forms/side/side.dart index 108c1e83..9a2fdd31 100644 --- a/example/lib/src/screens/home/config_view/forms/side/side.dart +++ b/example/lib/src/screens/home/config_view/forms/side/side.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../panels/map/map.dart'; -import '../../panels/stores/stores.dart'; +import '../../panels/stores/stores_list.dart'; class ConfigViewSide extends StatelessWidget { const ConfigViewSide({ @@ -34,7 +34,7 @@ class _ContentPanels extends StatelessWidget { Widget build(BuildContext context) => Padding( padding: const EdgeInsets.only(right: 16, top: 16), child: SizedBox( - width: 450, + width: 500, child: Column( children: [ DecoratedBox( @@ -86,8 +86,9 @@ class _ContentPanels extends StatelessWidget { slivers: [ SliverPadding( padding: EdgeInsets.only(top: 16), - sliver: ConfigPanelStoresSliver(), + sliver: StoresList(), ), + SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), ), diff --git a/example/lib/src/screens/home/config_view/forms/side_panel/side.dart b/example/lib/src/screens/home/config_view/forms/side_panel/side.dart deleted file mode 100644 index b8a489be..00000000 --- a/example/lib/src/screens/home/config_view/forms/side_panel/side.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../panels/behaviour/behaviour.dart'; -import '../../panels/map/map.dart'; -import '../../panels/stores/stores.dart'; - -class ConfigViewSide extends StatelessWidget { - const ConfigViewSide({ - super.key, - required this.selectedTab, - }); - - final int selectedTab; - - @override - Widget build(BuildContext context) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeOut, - transitionBuilder: (child, animation) => SizeTransition( - axis: Axis.horizontal, - axisAlignment: 1, // Align right - sizeFactor: animation, - child: child, - ), - child: - selectedTab == 0 ? const _ContentPanels() : const SizedBox.shrink(), - ); -} - -class _ContentPanels extends StatelessWidget { - const _ContentPanels(); - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(right: 16, top: 16), - child: SizedBox( - width: 450, - child: Column( - children: [ - DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(99), - color: Theme.of(context).colorScheme.surface, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: Row( - children: [ - Text( - 'Stores & Config', - style: Theme.of(context).textTheme.titleLarge, - ), - const Spacer(), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.help_outline), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.surface, - ), - padding: const EdgeInsets.all(16), - width: double.infinity, - child: const ConfigPanelBehaviour(), - ), - const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.surface, - ), - padding: const EdgeInsets.all(16), - width: double.infinity, - child: const ConfigPanelMap(), - ), - const SizedBox(height: 16), - Expanded( - child: ConstrainedBox( - constraints: const BoxConstraints.expand(), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - color: Theme.of(context).colorScheme.surface, - ), - child: const ConfigPanelStoresSliver(), - ), - ), - ), - ], - ), - ), - ); -} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart b/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart new file mode 100644 index 00000000..46fc951d --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; +import '../../../../../../shared/state/general_provider.dart'; + +class ColumnHeadersAndInheritableSettings extends StatelessWidget { + const ColumnHeadersAndInheritableSettings({ + super.key, + }); + + @override + Widget build(BuildContext context) => Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 28), + child: IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Tooltip( + message: 'Inherit', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.settings_suggest), + ), + ), + VerticalDivider(width: 2), + Tooltip( + message: 'Read only', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.visibility), + ), + ), + Tooltip( + message: ' + update existing', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.edit), + ), + ), + Tooltip( + message: ' + create new', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.add), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Selector( + selector: (context, provider) => + provider.inheritableStoreReadWriteBehaviour, + builder: (context, currentBehaviour, child) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: StoreReadWriteBehavior.values.map( + (e) { + final value = currentBehaviour == e + ? true + : InternalStoreReadWriteBehaviour.priority + .indexOf(currentBehaviour) < + InternalStoreReadWriteBehaviour.priority + .indexOf(e) + ? false + : null; + + return Checkbox.adaptive( + value: value, + onChanged: (v) => context + .read() + .inheritableStoreReadWriteBehaviour = + v == null ? null : e, + tristate: true, + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, + ); + }, + ).toList(growable: false), + ), + ), + ), + const Divider( + height: 8, + indent: 24, + endIndent: 24, + ), + ], + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart index b6c46598..9b6cf962 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart @@ -45,6 +45,7 @@ class StoreReadWriteBehaviourSelector extends StatelessWidget { } : null, materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, ), const VerticalDivider(width: 2), ...StoreReadWriteBehavior.values.map( @@ -116,5 +117,6 @@ class _StoreReadWriteBehaviourSelectorCheckbox extends StatelessWidget { : null, tristate: true, materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, ); } diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart index db997421..143efc54 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart @@ -135,7 +135,7 @@ class _StoreTileState extends State { ), AnimatedOpacity( opacity: matchesUrl ? 0 : 1, - duration: const Duration(milliseconds: 100), + duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, child: IgnorePointer( ignoring: matchesUrl, @@ -168,7 +168,7 @@ class _StoreTileState extends State { ), AnimatedOpacity( opacity: _toolsVisible ? 1 : 0, - duration: const Duration(milliseconds: 100), + duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, child: IgnorePointer( ignoring: !_toolsVisible, @@ -226,7 +226,8 @@ class _StoreTileState extends State { Future _hideTools() async { setState(() => _toolsVisible = false); - return Future.delayed(const Duration(milliseconds: 110)); + _toolsAutoHiderTimer?.cancel(); + return Future.delayed(const Duration(milliseconds: 150)); } void _showTools() { diff --git a/example/lib/src/screens/home/config_view/panels/stores/stores.dart b/example/lib/src/screens/home/config_view/panels/stores/stores.dart deleted file mode 100644 index 9c60ac03..00000000 --- a/example/lib/src/screens/home/config_view/panels/stores/stores.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../shared/misc/internal_store_read_write_behaviour.dart'; -import '../../../../../shared/state/general_provider.dart'; -import 'components/list.dart'; - -class ConfigPanelStoresSliver extends StatelessWidget { - const ConfigPanelStoresSliver({ - super.key, - }); - - @override - Widget build(BuildContext context) => SliverMainAxisGroup( - slivers: [ - const SliverPadding( - padding: EdgeInsets.symmetric(horizontal: 28), - sliver: SliverToBoxAdapter( - child: IntrinsicHeight( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Tooltip( - message: 'Inherit', - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.settings_suggest), - ), - ), - VerticalDivider(width: 2), - Tooltip( - message: 'Read only', - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.visibility), - ), - ), - Tooltip( - message: ' + update existing', - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.edit), - ), - ), - Tooltip( - message: ' + create new', - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.add), - ), - ), - ], - ), - ), - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 8)), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 28), - sliver: SliverToBoxAdapter( - child: Selector( - selector: (context, provider) => - provider.inheritableStoreReadWriteBehaviour, - builder: (context, currentBehaviour, child) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: StoreReadWriteBehavior.values.map( - (e) { - final value = currentBehaviour == e - ? true - : InternalStoreReadWriteBehaviour.priority - .indexOf(currentBehaviour) < - InternalStoreReadWriteBehaviour.priority - .indexOf(e) - ? false - : null; - - return Checkbox.adaptive( - value: value, - onChanged: (v) => context - .read() - .inheritableStoreReadWriteBehaviour = - v == null ? null : e, - tristate: true, - materialTapTargetSize: MaterialTapTargetSize.padded, - ); - }, - ).toList(growable: false), - ), - ), - ), - ), - const SliverToBoxAdapter( - child: Divider( - height: 8, - indent: 24, - endIndent: 24, - ), - ), - const StoresList(), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - ], - ); -} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/list.dart b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart similarity index 66% rename from example/lib/src/screens/home/config_view/panels/stores/components/list.dart rename to example/lib/src/screens/home/config_view/panels/stores/stores_list.dart index 3c7d8d9b..b868dab4 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/list.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'new_store_button.dart'; -import 'no_stores.dart'; -import 'store_tile.dart'; +import 'components/column_headers_and_inheritable_settings.dart'; +import 'components/new_store_button.dart'; +import 'components/no_stores.dart'; +import 'components/store_tile.dart'; class StoresList extends StatefulWidget { const StoresList({ @@ -48,14 +49,19 @@ class _StoresListState extends State { if (stores.isEmpty) return const NoStores(); return SliverList.separated( - itemCount: stores.length + 1, + itemCount: stores.length + 2, itemBuilder: (context, index) { - if (index == stores.length) return const NewStoreButton(); + if (index == 0) { + return const ColumnHeadersAndInheritableSettings(); + } + if (index - 1 == stores.length) { + return const NewStoreButton(); + } - final store = stores.keys.elementAt(index); - final stats = stores.values.elementAt(index).stats; - final metadata = stores.values.elementAt(index).metadata; - final tileImage = stores.values.elementAt(index).tileImage; + final store = stores.keys.elementAt(index - 1); + final stats = stores.values.elementAt(index - 1).stats; + final metadata = stores.values.elementAt(index - 1).metadata; + final tileImage = stores.values.elementAt(index - 1).tileImage; return StoreTile( store: store, @@ -64,7 +70,7 @@ class _StoresListState extends State { tileImage: tileImage, ); }, - separatorBuilder: (context, index) => index == stores.length - 1 + separatorBuilder: (context, index) => index - 1 == stores.length - 1 ? const Divider() : const SizedBox.shrink(), ); diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart index a97fa12a..231efb41 100644 --- a/example/lib/src/screens/home/map_view/map_view.dart +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -243,7 +243,6 @@ class _MapViewState extends State with TickerProviderStateMixin { return MapEntry(e, behaviour); }).whereNotNull(), ); - print(compiledStoreNames); final attribution = RichAttributionWidget( alignment: AttributionAlignment.bottomLeft, diff --git a/example/lib/src/screens/store_editor/store_editor.dart b/example/lib/src/screens/store_editor/store_editor.dart index 65365532..f9fb7e46 100644 --- a/example/lib/src/screens/store_editor/store_editor.dart +++ b/example/lib/src/screens/store_editor/store_editor.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; import '../../shared/components/url_selector.dart'; +import '../../shared/misc/shared_preferences.dart'; import '../../shared/misc/store_metadata_keys.dart'; import '../../shared/state/general_provider.dart'; @@ -20,9 +22,13 @@ class _StoreEditorPopupState extends State { late final String? existingStoreName; late final Future>? existingMetadata; - + late final Future? existingMaxLength; late final Future> existingStores; + String? newName; + String? newUrlTemplate; + int? newMaxLength; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -31,6 +37,9 @@ class _StoreEditorPopupState extends State { existingMetadata = existingStoreName == null ? null : FMTCStore(existingStoreName!).metadata.read; + existingMaxLength = existingStoreName == null + ? null + : FMTCStore(existingStoreName!).manage.maxLength; existingStores = FMTCRoot.stats.storesAvailable.then((l) => l.map((s) => s.storeName)); @@ -71,7 +80,7 @@ class _StoreEditorPopupState extends State { : input == '(default)' || input == '(custom)' ? 'Name reserved (in example app)' : null, - //onSaved: (input) => _newValues['storeName'] = input!, + onSaved: (input) => newName = input, maxLength: 64, autovalidateMode: AutovalidateMode.onUserInteraction, initialValue: existingStoreName, @@ -89,7 +98,7 @@ class _StoreEditorPopupState extends State { } return URLSelector( - onSelected: (_) {}, + onSelected: (input) => newUrlTemplate = input, initialValue: snapshot .data?[StoreMetadataKeys.urlTemplate.key] ?? context.select( @@ -100,12 +109,89 @@ class _StoreEditorPopupState extends State { ); }, ), + const SizedBox(height: 6), + FutureBuilder( + future: existingMaxLength, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done && + existingStoreName != null) { + return const CircularProgressIndicator.adaptive(); + } + + return TextFormField( + decoration: const InputDecoration( + labelText: 'Maximum Length', + helperText: 'Leave empty to disable limit', + suffixText: 'tiles', + prefixIcon: Icon(Icons.disc_full), + hintText: '∞', + filled: true, + ), + validator: (input) { + if ((input?.isNotEmpty ?? false) && + (int.tryParse(input!) ?? -1) < 0) { + return 'Must be empty, or greater than or equal to 0'; + } + return null; + }, + onSaved: (input) => newMaxLength = + input == null ? null : int.tryParse(input), + autovalidateMode: AutovalidateMode.onUserInteraction, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + initialValue: snapshot.data?.toString(), + textInputAction: TextInputAction.done, + ); + }, + ), ], ), ), ), ), ), + floatingActionButton: FloatingActionButton( + onPressed: () async { + if (!formKey.currentState!.validate()) return; + formKey.currentState!.save(); + + if (existingStoreName case final existingStoreName?) { + await FMTCStore(existingStoreName).manage.rename(newName!); + await FMTCStore(newName!).manage.setMaxLength(newMaxLength); + if (newUrlTemplate case final newUrlTemplate?) { + await FMTCStore(newName!).metadata.set( + key: StoreMetadataKeys.urlTemplate.key, + value: newUrlTemplate, + ); + } + } else { + final urlTemplate = + newUrlTemplate ?? context.read().urlTemplate; + + await FMTCStore(newName!).manage.create(maxLength: newMaxLength); + await FMTCStore(newName!).metadata.set( + key: StoreMetadataKeys.urlTemplate.key, + value: urlTemplate, + ); + + const sharedPrefsNonStoreUrlsKey = 'customNonStoreUrls'; + await sharedPrefs.setStringList( + sharedPrefsNonStoreUrlsKey, + (sharedPrefs.getStringList(sharedPrefsNonStoreUrlsKey) ?? + []) + ..remove(urlTemplate), + ); + } + + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + child: existingStoreName == null + ? const Icon(Icons.save) + : const Icon(Icons.save_as), + ), /*body: Consumer( builder: (context, provider, _) => Padding( padding: const EdgeInsets.all(12), From 0c76e3ff32d93f7856477c3d95a8257c62104204 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 28 Jul 2024 22:48:16 +0200 Subject: [PATCH 28/97] Minor example application improvements --- example/lib/main.dart | 18 +--- .../home/config_view/forms/side/side.dart | 13 ++- example/lib/src/screens/home/home.dart | 97 ++++++++++--------- 3 files changed, 61 insertions(+), 67 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index ca9ece0e..fdb0445f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -9,7 +9,6 @@ import 'src/screens/home/map_view/state/region_selection_provider.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; -import 'src/shared/misc/store_metadata_keys.dart'; import 'src/shared/state/general_provider.dart'; void main() async { @@ -24,14 +23,6 @@ void main() async { initErr = err; } - await const FMTCStore('Test Store').manage.create(); - await const FMTCStore('Test Store').metadata.setBulk( - kvs: { - StoreMetadataKeys.urlTemplate.key: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - }, - ); - runApp(_AppContainer(initialisationError: initErr)); } @@ -59,14 +50,7 @@ class _AppContainer extends StatelessWidget { fullscreenDialog: true, ), ), - /*ManageOfflineScreen.route: ( - std: (BuildContext context) => ManageOfflineScreen(), - custom: null, - ), - RegionSelectionScreen.route: ( - std: (BuildContext context) => const RegionSelectionScreen(), - custom: null, - ), + /* ProfileScreen.route: ( std: (BuildContext context) => const ProfileScreen(), custom: ({ diff --git a/example/lib/src/screens/home/config_view/forms/side/side.dart b/example/lib/src/screens/home/config_view/forms/side/side.dart index 9a2fdd31..21407213 100644 --- a/example/lib/src/screens/home/config_view/forms/side/side.dart +++ b/example/lib/src/screens/home/config_view/forms/side/side.dart @@ -7,9 +7,11 @@ class ConfigViewSide extends StatelessWidget { const ConfigViewSide({ super.key, required this.selectedTab, + required this.constraints, }); final int selectedTab; + final BoxConstraints constraints; @override Widget build(BuildContext context) => AnimatedSwitcher( @@ -22,19 +24,22 @@ class ConfigViewSide extends StatelessWidget { sizeFactor: animation, child: child, ), - child: - selectedTab == 0 ? const _ContentPanels() : const SizedBox.shrink(), + child: selectedTab == 0 + ? _ContentPanels(constraints) + : const SizedBox.shrink(), ); } class _ContentPanels extends StatelessWidget { - const _ContentPanels(); + const _ContentPanels(this.constraints); + + final BoxConstraints constraints; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.only(right: 16, top: 16), child: SizedBox( - width: 500, + width: (constraints.maxWidth / 3).clamp(430, 530), child: Column( children: [ DecoratedBox( diff --git a/example/lib/src/screens/home/home.dart b/example/lib/src/screens/home/home.dart index c9f0be6a..59aa7d18 100644 --- a/example/lib/src/screens/home/home.dart +++ b/example/lib/src/screens/home/home.dart @@ -113,57 +113,62 @@ class _HomeScreenState extends State { ).lerp(colorAnimation), body: child, ), - child: 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, + child: 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, + ), ), ), + onDestinationSelected: (i) => setState(() => selectedTab = i), ), - onDestinationSelected: (i) => setState(() => selectedTab = i), - ), - ConfigViewSide(selectedTab: selectedTab), - Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - bottomLeft: Radius.circular(16), - ), - child: MapView( - mode: mapMode, - layoutDirection: layoutDirection, + ConfigViewSide( + selectedTab: selectedTab, + constraints: constraints, + ), + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + child: MapView( + mode: mapMode, + layoutDirection: layoutDirection, + ), ), ), - ), - ], + ], + ), ), ); }, From b9b9e55f594a3ac5ef76db1da92f04f4d882c095 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 29 Jul 2024 11:54:36 +0200 Subject: [PATCH 29/97] Escape `link` & `delimiter` in `urlTransformerOmitKeyValues` internal regex --- .../tile_provider/tile_provider_settings.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/src/providers/tile_provider/tile_provider_settings.dart b/lib/src/providers/tile_provider/tile_provider_settings.dart index 8358bbea..79812941 100644 --- a/lib/src/providers/tile_provider/tile_provider_settings.dart +++ b/lib/src/providers/tile_provider/tile_provider_settings.dart @@ -169,7 +169,8 @@ class FMTCTileProviderSettings { /// parameter. /// /// Matching and removal is performed by a regular expression. Does not mutate - /// input [url]. + /// input [url]. [link] and [delimiter] are escaped (using [RegExp.escape]) + /// before they are used within the regular expression. /// /// This is not designed to be a security mechanism, and should not be relied /// upon as such. @@ -181,7 +182,13 @@ class FMTCTileProviderSettings { }) { var mutableUrl = url; for (final key in keys) { - mutableUrl = mutableUrl.replaceAll(RegExp('$key$link[^$delimiter]*'), ''); + mutableUrl = mutableUrl.replaceAll( + RegExp( + '${RegExp.escape(key)}${RegExp.escape(link)}' + '[^${RegExp.escape(delimiter)}]*', + ), + '', + ); } return mutableUrl; } From b351bc119bda8479c45eac6241bf5c85ddb268b2 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 29 Jul 2024 12:13:49 +0200 Subject: [PATCH 30/97] Updated CHANGELOG --- CHANGELOG.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6321907e..5f7cb1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,24 +21,26 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons ## [10.0.0] - 2024/XX/XX This update builds on v9 to fully embrace the new many-to-many relationship between tiles and stores, which allows for more flexibility when constructing the `FMTCTileProvider`. -This allows a new paradigm to be used: stores may now be treated as bulk downloaded regions, and all the regions/stores can be used at once - no more switching between them. This allows huge amounts of flexibility and a better UX in a complex application. +This allows a new paradigm to be used: stores may now be treated as bulk downloaded regions, and all the regions/stores can be used at once - no more switching between them. This allows huge amounts of flexibility and a better UX in a complex application. Additionally, each store may now have its own `StoreReadWriteBehavior` when browsing, which allows more flexibility: for example, stores may now contain more than one URL template/source, but control is retained. Additionally, vector tiles are now supported in theory, as the internal caching/retrieval logic of the specialised `ImageProvider` has been exposed, although it is out of scope to fully implement support for it. * Improvements to the browse caching logic and customizability - * Added support for using multiple stores simultaneously in the `FMTCTileProvider`, and exposed constructor directly + * Added support for using multiple stores simultaneously in the `FMTCTileProvider` (through `FMTCTileProvider.allStores` & `FMTCTileProvider.multipleStores` constructors) * Added `StoreReadWriteBehavior` for increased control over caching behaviour - * Added toggle for hit/miss stat recording, to improve performance where these statistics are never read * Added Tile Loading Debug system (`FMTCTileProvider.tileLoadingDebugger`) to provide a method to debug internal tile loading mechanisms and perform advanced custom logging + * Added toggle for hit/miss stat recording, to improve performance where these statistics are never read * Replaced `FMTCTileProviderSettings.maxStoreLength` with a `maxLength` property on each store individually - * Refactored and exposed tile provider logic into seperate `getBytes` method -* Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `urlTransformerOmitKeyValues` utility method to provide old behaviour) + * Refactored and exposed tile provider logic into seperate and externally visible (via `FMTCTileProvider.getBytes`) method + * `FMTCBrowsingErrorHandler` callback may now return bytes to be displayed instead of (re)throwing exception +* Improved speed (by massive amounts) and accuracy & reduced memory consumption of `CircleRegion`'s tile generation & counting algorithm +* Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `urlTransformerOmitKeyValues` utility method to provide old behaviour with more customizability) * Removed deprecated remnants from v9.* * Other generic improvements ## [9.1.1] - 2024/07/16 -* Fixed bug where errors within the import functionality would not be catchable by the original invoker +* Fixed bug where errors within the import functionality would not always be catchable by the original invoker * Minor other improvements ## [9.1.0] - 2024/05/27 From 9ce9f6784f7ed95c1d9b0549f5472f0f15361f94 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 9 Aug 2024 14:01:34 +0200 Subject: [PATCH 31/97] Removed `FMTCTileProviderSettings` & transferred properties to `FMTCTileProvider` Renamed `CacheBehavior` to `BrowseLoadingStrategy` Renamed `StoreReadWriteBehavior` to `BrowseStoreStrategy` Renamed `tileLoadingDebugger` to `tileLoadingInterceptor` Renamed `FMTCBrowsingErrorHandler` to `BrowsingExceptionHandler` Minor documentation improvements --- CHANGELOG.md | 18 +- .../panels/behaviour/behaviour.dart | 16 +- ...lumn_headers_and_inheritable_settings.dart | 13 +- .../store_read_write_behaviour_selector.dart | 42 ++- .../components/debugging_tile_builder.dart | 6 +- .../src/screens/home/map_view/map_view.dart | 16 +- .../internal_store_read_write_behaviour.dart | 36 +-- .../src/shared/state/general_provider.dart | 24 +- lib/flutter_map_tile_caching.dart | 11 +- lib/src/backend/interfaces/models.dart | 2 +- .../image_provider/browsing_errors.dart | 12 +- .../image_provider/image_provider.dart | 19 +- .../image_provider/internal_get_bytes.dart | 79 ++--- .../tile_loading_debug/map_typedef.dart | 10 - .../tile_loading_interceptor/map_typedef.dart | 11 + .../result.dart} | 33 +- .../result_path.dart | 9 +- .../custom_user_agent_compat_map.dart | 34 +++ .../{behaviours.dart => strategies.dart} | 36 +-- .../tile_provider/tile_provider.dart | 287 ++++++++++++------ .../tile_provider/tile_provider_settings.dart | 221 -------------- lib/src/providers/tile_provider/typedefs.dart | 12 + lib/src/store/download.dart | 28 +- lib/src/store/store.dart | 35 ++- pubspec.yaml | 2 +- 25 files changed, 468 insertions(+), 544 deletions(-) delete mode 100644 lib/src/providers/tile_loading_debug/map_typedef.dart create mode 100644 lib/src/providers/tile_loading_interceptor/map_typedef.dart rename lib/src/providers/{tile_loading_debug/info.dart => tile_loading_interceptor/result.dart} (74%) rename lib/src/providers/{tile_loading_debug => tile_loading_interceptor}/result_path.dart (65%) create mode 100644 lib/src/providers/tile_provider/custom_user_agent_compat_map.dart rename lib/src/providers/tile_provider/{behaviours.dart => strategies.dart} (63%) delete mode 100644 lib/src/providers/tile_provider/tile_provider_settings.dart create mode 100644 lib/src/providers/tile_provider/typedefs.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7527c149..de692f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,29 +18,31 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons # Changelog -## [10.0.0] - 2024/XX/XX +## [10.0.0] - "Better Browsing" - 2024/XX/XX This update builds on v9 to fully embrace the new many-to-many relationship between tiles and stores, which allows for more flexibility when constructing the `FMTCTileProvider`. -This allows a new paradigm to be used: stores may now be treated as bulk downloaded regions, and all the regions/stores can be used at once - no more switching between them. This allows huge amounts of flexibility and a better UX in a complex application. Additionally, each store may now have its own `StoreReadWriteBehavior` when browsing, which allows more flexibility: for example, stores may now contain more than one URL template/source, but control is retained. +This allows a new paradigm to be used: stores may now be treated as bulk downloaded regions, and all the regions/stores can be used at once - no more switching between them. This allows huge amounts of flexibility and a better UX in a complex application. Additionally, each store may now have its own `BrowseStoreStrategy` when browsing, which allows more flexibility: for example, stores may now contain more than one URL template/source, but control is retained. Additionally, vector tiles are now supported in theory, as the internal caching/retrieval logic of the specialised `ImageProvider` has been exposed, although it is out of scope to fully implement support for it. * Improvements to the browse caching logic and customizability * Added support for using multiple stores simultaneously in the `FMTCTileProvider` (through `FMTCTileProvider.allStores` & `FMTCTileProvider.multipleStores` constructors) - * Added `StoreReadWriteBehavior` for increased control over caching behaviour - * Added Tile Loading Debug system (`FMTCTileProvider.tileLoadingDebugger`) to provide a method to debug internal tile loading mechanisms and perform advanced custom logging + * Added `FMTCTileProvider.getBytes` method to expose internal caching mechanisms for external use + * Added `BrowseStoreStrategy` for increased control over caching behaviour + * Added 'tile loading interceptor' feature (`FMTCTileProvider.tileLoadingInterceptor`) to track (eg. for debugging and logging) the internal tile loading mechanisms * Added toggle for hit/miss stat recording, to improve performance where these statistics are never read * Replaced `FMTCTileProviderSettings.maxStoreLength` with a `maxLength` property on each store individually - * Refactored and exposed tile provider logic into seperate and externally visible (via `FMTCTileProvider.getBytes`) method - * `FMTCBrowsingErrorHandler` callback may now return bytes to be displayed instead of (re)throwing exception + * Replaced `CacheBehavior` with `BrowseLoadingStrategy` + * Replaced `FMTCBrowsingErrorHandler` with `BrowsingExceptionHandler`, which may now return bytes to be displayed instead of (re)throwing exception + * Removed `FMTCTileProviderSettings` & absorbed properties directly into `FMTCTileProvider` * Improved speed (by massive amounts) and accuracy & reduced memory consumption of `CircleRegion`'s tile generation & counting algorithm -* Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `urlTransformerOmitKeyValues` utility method to provide old behaviour with more customizability) +* Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `FMTCTileProvider.urlTransformerOmitKeyValues` utility method to provide old behaviour with more customizability) * Removed deprecated remnants from v9.* * Other generic improvements ## [9.1.2] - 2024/08/07 -* Fixed compilation on web platforms: FMTC now internally overrides the `FMTCObjectBoxBackend` and becomes a no-op +* Fixed compilation on web platforms: FMTC now internally overrides the `FMTCObjectBoxBackend` and becomes a no-op on non-FFI platforms * Minor documentation improvements ## [9.1.1] - 2024/07/16 diff --git a/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart b/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart index 7497e43f..0e8cdd48 100644 --- a/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart +++ b/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart @@ -11,9 +11,9 @@ class ConfigPanelBehaviour extends StatelessWidget { @override Widget build(BuildContext context) => - Selector( - selector: (context, provider) => provider.cacheBehavior, - builder: (context, cacheBehavior, _) => Column( + Selector( + selector: (context, provider) => provider.loadingStrategy, + builder: (context, loadingStrategy, _) => Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( @@ -21,25 +21,25 @@ class ConfigPanelBehaviour extends StatelessWidget { child: SegmentedButton( segments: const [ ButtonSegment( - value: CacheBehavior.cacheOnly, + value: BrowseLoadingStrategy.cacheOnly, icon: Icon(Icons.download_for_offline_outlined), label: Text('Cache Only'), ), ButtonSegment( - value: CacheBehavior.cacheFirst, + value: BrowseLoadingStrategy.cacheFirst, icon: Icon(Icons.storage_rounded), label: Text('Cache'), ), ButtonSegment( - value: CacheBehavior.onlineFirst, + value: BrowseLoadingStrategy.onlineFirst, icon: Icon(Icons.public_rounded), label: Text('Network'), ), ], - selected: {cacheBehavior}, + selected: {loadingStrategy}, onSelectionChanged: (value) => context .read() - .cacheBehavior = value.single, + .loadingStrategy = value.single, style: const ButtonStyle( visualDensity: VisualDensity.comfortable, ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart b/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart index 46fc951d..e3db893e 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart @@ -55,19 +55,18 @@ class ColumnHeadersAndInheritableSettings extends StatelessWidget { const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 28), - child: Selector( + child: Selector( selector: (context, provider) => - provider.inheritableStoreReadWriteBehaviour, + provider.inheritableBrowseStoreStrategy, builder: (context, currentBehaviour, child) => Row( mainAxisAlignment: MainAxisAlignment.end, - children: StoreReadWriteBehavior.values.map( + children: BrowseStoreStrategy.values.map( (e) { final value = currentBehaviour == e ? true - : InternalStoreReadWriteBehaviour.priority + : InternalBrowseStoreStrategy.priority .indexOf(currentBehaviour) < - InternalStoreReadWriteBehaviour.priority - .indexOf(e) + InternalBrowseStoreStrategy.priority.indexOf(e) ? false : null; @@ -75,7 +74,7 @@ class ColumnHeadersAndInheritableSettings extends StatelessWidget { value: value, onChanged: (v) => context .read() - .inheritableStoreReadWriteBehaviour = + .inheritableBrowseStoreStrategy = v == null ? null : e, tristate: true, materialTapTargetSize: MaterialTapTargetSize.padded, diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart index 9b6cf962..2b95a1ae 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart @@ -17,29 +17,29 @@ class StoreReadWriteBehaviourSelector extends StatelessWidget { @override Widget build(BuildContext context) => - Selector( + Selector( selector: (context, provider) => provider.currentStores[storeName], builder: (context, currentBehaviour, child) => - Selector( + Selector( selector: (context, provider) => - provider.inheritableStoreReadWriteBehaviour, + provider.inheritableBrowseStoreStrategy, builder: (context, inheritableBehaviour, _) => Row( mainAxisSize: MainAxisSize.min, children: [ Checkbox.adaptive( - value: currentBehaviour == - InternalStoreReadWriteBehaviour.inherit || - currentBehaviour == null, + value: + currentBehaviour == InternalBrowseStoreStrategy.inherit || + currentBehaviour == null, onChanged: enabled ? (v) { final provider = context.read(); provider ..currentStores[storeName] = v! - ? InternalStoreReadWriteBehaviour.inherit - : InternalStoreReadWriteBehaviour - .fromStoreReadWriteBehavior( - provider.inheritableStoreReadWriteBehaviour, + ? InternalBrowseStoreStrategy.inherit + : InternalBrowseStoreStrategy + .fromBrowseStoreStrategy( + provider.inheritableBrowseStoreStrategy, ) ..changedCurrentStores(); } @@ -48,14 +48,14 @@ class StoreReadWriteBehaviourSelector extends StatelessWidget { visualDensity: VisualDensity.comfortable, ), const VerticalDivider(width: 2), - ...StoreReadWriteBehavior.values.map( + ...BrowseStoreStrategy.values.map( (e) => _StoreReadWriteBehaviourSelectorCheckbox( storeName: storeName, representativeBehaviour: e, currentBehaviour: currentBehaviour == null ? inheritableBehaviour : currentBehaviour - .toStoreReadWriteBehavior(inheritableBehaviour), + .toBrowseStoreStrategy(inheritableBehaviour), enabled: enabled, ), ), @@ -74,17 +74,16 @@ class _StoreReadWriteBehaviourSelectorCheckbox extends StatelessWidget { }); final String storeName; - final StoreReadWriteBehaviour representativeBehaviour; - final StoreReadWriteBehaviour? currentBehaviour; + final BrowseStoreStrategy representativeBehaviour; + final BrowseStoreStrategy? currentBehaviour; final bool enabled; @override Widget build(BuildContext context) => Checkbox.adaptive( value: currentBehaviour == representativeBehaviour ? true - : InternalStoreReadWriteBehaviour.priority - .indexOf(currentBehaviour) < - InternalStoreReadWriteBehaviour.priority + : InternalBrowseStoreStrategy.priority.indexOf(currentBehaviour) < + InternalBrowseStoreStrategy.priority .indexOf(representativeBehaviour) ? false : null, @@ -96,19 +95,18 @@ class _StoreReadWriteBehaviourSelectorCheckbox extends StatelessWidget { // Deselected current selection // > Disable inheritance and disable store provider.currentStores[storeName] = - InternalStoreReadWriteBehaviour.disable; + InternalBrowseStoreStrategy.disable; } else if (representativeBehaviour == - provider.inheritableStoreReadWriteBehaviour) { + provider.inheritableBrowseStoreStrategy) { // Selected same as inherited // > Automatically enable inheritance (assumed desire, can be undone) provider.currentStores[storeName] = - InternalStoreReadWriteBehaviour.inherit; + InternalBrowseStoreStrategy.inherit; } else { // Selected something else // > Disable inheritance and change store provider.currentStores[storeName] = - InternalStoreReadWriteBehaviour - .fromStoreReadWriteBehavior( + InternalBrowseStoreStrategy.fromBrowseStoreStrategy( representativeBehaviour, ); } diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart b/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart index 294f1820..d49faf64 100644 --- a/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart +++ b/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart @@ -13,7 +13,7 @@ class DebuggingTileBuilder extends StatelessWidget { final Widget tileWidget; final TileImage tile; - final ValueNotifier tileLoadingDebugger; + final ValueNotifier tileLoadingDebugger; final bool usingFMTC; @override @@ -80,7 +80,7 @@ class DebuggingTileBuilder extends StatelessWidget { : 'Unknown error', textAlign: TextAlign.center, ), - if (info.result case final result?) ...[ + if (info.resultPath case final result?) ...[ Text( "'${result.name}' in " '${tile.loadFinishedAt == null || tile.loadStarted == null ? 'Loading...' : '${tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms'}\n', @@ -96,7 +96,7 @@ class DebuggingTileBuilder extends StatelessWidget { 'New tile', textAlign: TextAlign.center, ), - if (info.writeResult case final writeResult?) + if (info.storesWriteResult case final writeResult?) FutureBuilder( future: writeResult, builder: (context, snapshot) { diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart index 231efb41..1a05cbcf 100644 --- a/example/lib/src/screens/home/map_view/map_view.dart +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -52,7 +52,7 @@ class _MapViewState extends State with TickerProviderStateMixin { duration: MapView.animationDuration, ); - final _tileLoadingDebugger = ValueNotifier({}); + final _tileLoadingDebugger = ValueNotifier({}); late final _storesStream = FMTCRoot.stats.watchStores(triggerImmediately: true).asyncMap( @@ -235,9 +235,9 @@ class _MapViewState extends State with TickerProviderStateMixin { .map((e) { final internalBehaviour = provider.currentStores[e]; final behaviour = internalBehaviour == null - ? provider.inheritableStoreReadWriteBehaviour - : internalBehaviour.toStoreReadWriteBehavior( - provider.inheritableStoreReadWriteBehaviour, + ? provider.inheritableBrowseStoreStrategy + : internalBehaviour.toBrowseStoreStrategy( + provider.inheritableBrowseStoreStrategy, ); if (behaviour == null) return null; return MapEntry(e, behaviour); @@ -279,11 +279,9 @@ class _MapViewState extends State with TickerProviderStateMixin { ? NetworkTileProvider() : FMTCTileProvider.multipleStores( storeNames: compiledStoreNames, - settings: FMTCTileProviderSettings( - behavior: provider.cacheBehavior, - recordHitsAndMisses: false, - ), - tileLoadingDebugger: _tileLoadingDebugger, + loadingStrategy: provider.loadingStrategy, + recordHitsAndMisses: false, + tileLoadingInterceptor: _tileLoadingDebugger, ), tileBuilder: !provider.displayDebugOverlay ? null diff --git a/example/lib/src/shared/misc/internal_store_read_write_behaviour.dart b/example/lib/src/shared/misc/internal_store_read_write_behaviour.dart index 61df845c..70a08d14 100644 --- a/example/lib/src/shared/misc/internal_store_read_write_behaviour.dart +++ b/example/lib/src/shared/misc/internal_store_read_write_behaviour.dart @@ -2,8 +2,8 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; /// Determines the read/update/create tile behaviour of a store /// -/// Expands [StoreReadWriteBehaviour]. -enum InternalStoreReadWriteBehaviour { +/// Expands [BrowseStoreStrategy]. +enum InternalBrowseStoreStrategy { /// Disable store entirely disable, @@ -24,33 +24,33 @@ enum InternalStoreReadWriteBehaviour { /// See [readUpdate] for a definition of 'update'. readUpdateCreate; - StoreReadWriteBehavior? toStoreReadWriteBehavior([ - StoreReadWriteBehavior? inheritableBehaviour, + BrowseStoreStrategy? toBrowseStoreStrategy([ + BrowseStoreStrategy? inheritableBehaviour, ]) => switch (this) { disable => null, inherit => inheritableBehaviour, - read => StoreReadWriteBehavior.read, - readUpdate => StoreReadWriteBehavior.readUpdate, - readUpdateCreate => StoreReadWriteBehavior.readUpdateCreate, + read => BrowseStoreStrategy.read, + readUpdate => BrowseStoreStrategy.readUpdate, + readUpdateCreate => BrowseStoreStrategy.readUpdateCreate, }; - static InternalStoreReadWriteBehaviour fromStoreReadWriteBehavior( - StoreReadWriteBehavior? behaviour, + static InternalBrowseStoreStrategy fromBrowseStoreStrategy( + BrowseStoreStrategy? behaviour, ) => switch (behaviour) { - null => InternalStoreReadWriteBehaviour.disable, - StoreReadWriteBehavior.read => InternalStoreReadWriteBehaviour.read, - StoreReadWriteBehavior.readUpdate => - InternalStoreReadWriteBehaviour.readUpdate, - StoreReadWriteBehavior.readUpdateCreate => - InternalStoreReadWriteBehaviour.readUpdateCreate, + null => InternalBrowseStoreStrategy.disable, + BrowseStoreStrategy.read => InternalBrowseStoreStrategy.read, + BrowseStoreStrategy.readUpdate => + InternalBrowseStoreStrategy.readUpdate, + BrowseStoreStrategy.readUpdateCreate => + InternalBrowseStoreStrategy.readUpdateCreate, }; static const priority = [ null, - StoreReadWriteBehaviour.read, - StoreReadWriteBehaviour.readUpdate, - StoreReadWriteBehaviour.readUpdateCreate, + BrowseStoreStrategy.read, + BrowseStoreStrategy.readUpdate, + BrowseStoreStrategy.readUpdateCreate, ]; } diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart index 40423874..53479544 100644 --- a/example/lib/src/shared/state/general_provider.dart +++ b/example/lib/src/shared/state/general_provider.dart @@ -4,18 +4,16 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import '../misc/internal_store_read_write_behaviour.dart'; class GeneralProvider extends ChangeNotifier { - StoreReadWriteBehaviour? _inheritableStoreReadWriteBehaviour = - StoreReadWriteBehaviour.readUpdateCreate; - StoreReadWriteBehaviour? get inheritableStoreReadWriteBehaviour => - _inheritableStoreReadWriteBehaviour; - set inheritableStoreReadWriteBehaviour( - StoreReadWriteBehaviour? newBehaviour, - ) { - _inheritableStoreReadWriteBehaviour = newBehaviour; + BrowseStoreStrategy? _inheritableBrowseStoreStrategy = + BrowseStoreStrategy.readUpdateCreate; + BrowseStoreStrategy? get inheritableBrowseStoreStrategy => + _inheritableBrowseStoreStrategy; + set inheritableBrowseStoreStrategy(BrowseStoreStrategy? newStoreStrategy) { + _inheritableBrowseStoreStrategy = newStoreStrategy; notifyListeners(); } - final Map currentStores = {}; + final Map currentStores = {}; void changedCurrentStores() => notifyListeners(); String _urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; @@ -25,10 +23,10 @@ class GeneralProvider extends ChangeNotifier { notifyListeners(); } - CacheBehavior _cacheBehavior = CacheBehavior.cacheFirst; - CacheBehavior get cacheBehavior => _cacheBehavior; - set cacheBehavior(CacheBehavior newCacheBehavior) { - _cacheBehavior = newCacheBehavior; + BrowseLoadingStrategy _loadingStrategy = BrowseLoadingStrategy.cacheFirst; + BrowseLoadingStrategy get loadingStrategy => _loadingStrategy; + set loadingStrategy(BrowseLoadingStrategy newLoadingStrategy) { + _loadingStrategy = newLoadingStrategy; notifyListeners(); } diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index a662ec35..4e44b9c0 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -45,14 +45,15 @@ part 'src/bulk_download/external/tile_event.dart'; part 'src/bulk_download/internal/control_cmds.dart'; part 'src/bulk_download/internal/manager.dart'; part 'src/bulk_download/internal/thread.dart'; -part 'src/providers/tile_loading_debug/info.dart'; -part 'src/providers/tile_loading_debug/map_typedef.dart'; -part 'src/providers/tile_loading_debug/result_path.dart'; +part 'src/providers/tile_loading_interceptor/result.dart'; +part 'src/providers/tile_loading_interceptor/map_typedef.dart'; +part 'src/providers/tile_loading_interceptor/result_path.dart'; part 'src/providers/image_provider/image_provider.dart'; part 'src/providers/image_provider/internal_get_bytes.dart'; -part 'src/providers/tile_provider/behaviours.dart'; +part 'src/providers/tile_provider/custom_user_agent_compat_map.dart'; +part 'src/providers/tile_provider/strategies.dart'; part 'src/providers/tile_provider/tile_provider.dart'; -part 'src/providers/tile_provider/tile_provider_settings.dart'; +part 'src/providers/tile_provider/typedefs.dart'; part 'src/regions/base_region.dart'; part 'src/regions/downloadable_region.dart'; part 'src/regions/recovered_region.dart'; diff --git a/lib/src/backend/interfaces/models.dart b/lib/src/backend/interfaces/models.dart index 93b4d82c..73a04416 100644 --- a/lib/src/backend/interfaces/models.dart +++ b/lib/src/backend/interfaces/models.dart @@ -14,7 +14,7 @@ import '../../../flutter_map_tile_caching.dart'; abstract base class BackendTile { /// The storage-suitable UID of the tile /// - /// This is the result of [FMTCTileProviderSettings.urlTransformer]. + /// This is the result of [FMTCTileProvider.urlTransformer]. String get url; /// The time at which the [bytes] of this tile were last changed diff --git a/lib/src/providers/image_provider/browsing_errors.dart b/lib/src/providers/image_provider/browsing_errors.dart index 52c48edb..b75eff40 100644 --- a/lib/src/providers/image_provider/browsing_errors.dart +++ b/lib/src/providers/image_provider/browsing_errors.dart @@ -13,7 +13,7 @@ import '../../../flutter_map_tile_caching.dart'; /// /// These can usually be safely ignored, as they simply represent a fall /// through of all valid/possible cases, but you may wish to handle them -/// anyway using [FMTCTileProviderSettings.errorHandler]. +/// anyway using [FMTCTileProvider.errorHandler]. /// /// Use [type] to establish the condition that threw this exception, and /// [message] for a user-friendly English description of this exception. Also @@ -24,7 +24,7 @@ class FMTCBrowsingError implements Exception { /// /// These can usually be safely ignored, as they simply represent a fall /// through of all valid/possible cases, but you may wish to handle them - /// anyway using [FMTCTileProviderSettings.errorHandler]. + /// anyway using [FMTCTileProvider.errorHandler]. /// /// Use [type] to establish the condition that threw this exception, and /// [message] for a user-friendly English description of this exception. Also @@ -56,7 +56,7 @@ class FMTCBrowsingError implements Exception { final String networkUrl; /// The storage-suitable UID of the tile: the result of - /// [FMTCTileProviderSettings.urlTransformer] on [networkUrl] + /// [FMTCTileProvider.urlTransformer] on [networkUrl] final String storageSuitableUID; /// If available, the attempted HTTP request @@ -93,10 +93,12 @@ class FMTCBrowsingError implements Exception { enum FMTCBrowsingErrorType { /// Failed to load the tile from the cache because it was missing /// - /// Ensure that tiles are cached before using [CacheBehavior.cacheOnly]. + /// Ensure that tiles are cached before using + /// [BrowseLoadingStrategy.cacheOnly]. missingInCacheOnlyMode( 'Failed to load the tile from the cache because it was missing.', - 'Ensure that tiles are cached before using `CacheBehavior.cacheOnly`.', + 'Ensure that tiles are cached before using ' + '`BrowseLoadingStrategy.cacheOnly`.', ), /// Failed to load the tile from the cache or the network because it was diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index da0085a5..0d7eb523 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -117,8 +117,9 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { void Function()? finishedLoadingBytes, bool requireValidImage = false, }) async { - final currentTileDebugNotifierInfo = - provider.tileLoadingDebugger != null ? TileLoadingDebugInfo._() : null; + final currentTLIR = provider.tileLoadingInterceptor != null + ? TileLoadingInterceptorResult._() + : null; void close([Object? error]) { finishedLoadingBytes?.call(); @@ -130,12 +131,12 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { unawaited(chunkEvents.close()); } - if (currentTileDebugNotifierInfo != null) { - currentTileDebugNotifierInfo.error = error; - if (error != null) currentTileDebugNotifierInfo.result = null; + if (currentTLIR != null) { + currentTLIR.error = error; + if (error != null) currentTLIR.resultPath = null; - provider.tileLoadingDebugger! - ..value[coords] = currentTileDebugNotifierInfo + provider.tileLoadingInterceptor! + ..value[coords] = currentTLIR // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member ..notifyListeners(); } @@ -151,13 +152,13 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { provider: provider, chunkEvents: chunkEvents, requireValidImage: requireValidImage, - currentTLDI: currentTileDebugNotifierInfo, + currentTLIR: currentTLIR, ); } catch (err, stackTrace) { close(err); if (err is FMTCBrowsingError) { - final handlerResult = provider.settings.errorHandler?.call(err); + final handlerResult = provider.errorHandler?.call(err); if (handlerResult != null) return handlerResult; } diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index 89d38924..da64a1e7 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -9,19 +9,19 @@ Future _internalGetBytes({ required FMTCTileProvider provider, required StreamController? chunkEvents, required bool requireValidImage, - required TileLoadingDebugInfo? currentTLDI, + required TileLoadingInterceptorResult? currentTLIR, }) async { void registerHit(List storeNames) { - currentTLDI?.hitOrMiss = true; - if (provider.settings.recordHitsAndMisses) { + currentTLIR?.hitOrMiss = true; + if (provider.recordHitsAndMisses) { FMTCBackendAccess.internal .registerHitOrMiss(storeNames: storeNames, hit: true); } } void registerMiss() { - currentTLDI?.hitOrMiss = false; - if (provider.settings.recordHitsAndMisses) { + currentTLIR?.hitOrMiss = false; + if (provider.recordHitsAndMisses) { FMTCBackendAccess.internal.registerHitOrMiss( storeNames: provider._getSpecifiedStoresOrNull(), // TODO: Verify hit: false, @@ -30,10 +30,10 @@ Future _internalGetBytes({ } final networkUrl = provider.getTileUrl(coords, options); - final matcherUrl = provider.settings.urlTransformer(networkUrl); + final matcherUrl = provider.urlTransformer(networkUrl); - currentTLDI?.networkUrl = networkUrl; - currentTLDI?.storageSuitableUID = matcherUrl; + currentTLIR?.networkUrl = networkUrl; + currentTLIR?.storageSuitableUID = matcherUrl; final ( tile: existingTile, @@ -44,11 +44,11 @@ Future _internalGetBytes({ storeNames: provider._getSpecifiedStoresOrNull(), ); - currentTLDI?.existingStores = + currentTLIR?.existingStores = allExistingStores.isEmpty ? null : allExistingStores; final tileExistsInUnspecifiedStoresOnly = existingTile != null && - provider.settings.useOtherStoresAsFallbackOnly && + provider.useOtherStoresAsFallbackOnly && provider.storeNames.keys .toSet() .union( @@ -56,7 +56,7 @@ Future _internalGetBytes({ ) // TODO: Verify (intersect? simplify?) .isEmpty; - currentTLDI?.tileExistsInUnspecifiedStoresOnly = + currentTLIR?.tileExistsInUnspecifiedStoresOnly = tileExistsInUnspecifiedStoresOnly; // Prepare a list of image bytes and prefill if there's already a cached @@ -66,19 +66,20 @@ Future _internalGetBytes({ // If there is a cached tile that's in date available, use it final needsUpdating = existingTile != null && - (provider.settings.behavior == CacheBehavior.onlineFirst || - (provider.settings.cachedValidDuration != Duration.zero && + (provider.loadingStrategy == BrowseLoadingStrategy.onlineFirst || + (provider.cachedValidDuration != Duration.zero && DateTime.timestamp().millisecondsSinceEpoch - existingTile.lastModified.millisecondsSinceEpoch > - provider.settings.cachedValidDuration.inMilliseconds)); + provider.cachedValidDuration.inMilliseconds)); - currentTLDI?.needsUpdating = needsUpdating; + currentTLIR?.needsUpdating = needsUpdating; if (existingTile != null && !needsUpdating && !tileExistsInUnspecifiedStoresOnly) { - currentTLDI?.result = TileLoadingDebugResultPath.perfectFromStores; - currentTLDI?.writeResult = null; + currentTLIR?.resultPath = + TileLoadingInterceptorResultPath.perfectFromStores; + currentTLIR?.storesWriteResult = null; registerHit(intersectedExistingStores); return bytes!; @@ -86,10 +87,11 @@ Future _internalGetBytes({ // If a tile is not available and cache only mode is in use, just fail // before attempting a network call - if (provider.settings.behavior == CacheBehavior.cacheOnly) { + if (provider.loadingStrategy == BrowseLoadingStrategy.cacheOnly) { if (existingTile != null) { - currentTLDI?.result = TileLoadingDebugResultPath.cacheOnlyFromOtherStores; - currentTLDI?.writeResult = null; + currentTLIR?.resultPath = + TileLoadingInterceptorResultPath.cacheOnlyFromOtherStores; + currentTLIR?.storesWriteResult = null; registerMiss(); return bytes!; @@ -101,6 +103,7 @@ Future _internalGetBytes({ storageSuitableUID: matcherUrl, ); + // TODO: remove below /*if (tileExistsInUnspecifiedStoresOnly) { registerMiss(); return bytes!; @@ -122,8 +125,8 @@ Future _internalGetBytes({ response = await provider.httpClient.send(request); } catch (e) { if (existingTile != null) { - currentTLDI?.result = TileLoadingDebugResultPath.noFetch; - currentTLDI?.writeResult = null; + currentTLIR?.resultPath = TileLoadingInterceptorResultPath.noFetch; + currentTLIR?.storesWriteResult = null; registerMiss(); return bytes!; @@ -143,8 +146,8 @@ Future _internalGetBytes({ // Check whether the network response is not 200 OK if (response.statusCode != 200) { if (existingTile != null) { - currentTLDI?.result = TileLoadingDebugResultPath.noFetch; - currentTLDI?.writeResult = null; + currentTLIR?.resultPath = TileLoadingInterceptorResultPath.noFetch; + currentTLIR?.storesWriteResult = null; registerMiss(); return bytes!; @@ -195,8 +198,8 @@ Future _internalGetBytes({ if (isValidImageData != null) { if (existingTile != null) { - currentTLDI?.result = TileLoadingDebugResultPath.noFetch; - currentTLDI?.writeResult = null; + currentTLIR?.resultPath = TileLoadingInterceptorResultPath.noFetch; + currentTLIR?.storesWriteResult = null; registerMiss(); return bytes!; @@ -220,16 +223,16 @@ Future _internalGetBytes({ final writeTileToSpecified = provider.storeNames.entries .where( (e) => switch (e.value) { - StoreReadWriteBehavior.read => false, - StoreReadWriteBehavior.readUpdate => + BrowseStoreStrategy.read => false, + BrowseStoreStrategy.readUpdate => intersectedExistingStores.contains(e.key), - StoreReadWriteBehavior.readUpdateCreate => true, + BrowseStoreStrategy.readUpdateCreate => true, }, ) .map((e) => e.key); final writeTileToIntermediate = - (provider.otherStoresBehavior == StoreReadWriteBehavior.readUpdate && + (provider.otherStoresStrategy == BrowseStoreStrategy.readUpdate && existingTile != null ? writeTileToSpecified.followedBy( intersectedExistingStores @@ -241,13 +244,13 @@ Future _internalGetBytes({ // Cache tile to necessary stores if (writeTileToIntermediate.isNotEmpty || - provider.otherStoresBehavior == StoreReadWriteBehavior.readUpdateCreate) { - currentTLDI?.writeResult = FMTCBackendAccess.internal.writeTile( + provider.otherStoresStrategy == BrowseStoreStrategy.readUpdateCreate) { + currentTLIR?.storesWriteResult = FMTCBackendAccess.internal.writeTile( storeNames: writeTileToIntermediate, - writeAllNotIn: provider.otherStoresBehavior == - StoreReadWriteBehavior.readUpdateCreate - ? provider.storeNames.keys.toList(growable: false) - : null, + writeAllNotIn: + provider.otherStoresStrategy == BrowseStoreStrategy.readUpdateCreate + ? provider.storeNames.keys.toList(growable: false) + : null, url: matcherUrl, bytes: responseBytes, // ignore: unawaited_futures @@ -264,10 +267,10 @@ Future _internalGetBytes({ ); }); } else { - currentTLDI?.writeResult = null; + currentTLIR?.storesWriteResult = null; } - currentTLDI?.result = TileLoadingDebugResultPath.fetched; + currentTLIR?.resultPath = TileLoadingInterceptorResultPath.fetched; registerMiss(); return responseBytes; diff --git a/lib/src/providers/tile_loading_debug/map_typedef.dart b/lib/src/providers/tile_loading_debug/map_typedef.dart deleted file mode 100644 index 04ca0029..00000000 --- a/lib/src/providers/tile_loading_debug/map_typedef.dart +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of '../../../flutter_map_tile_caching.dart'; - -/// Mapping of [TileCoordinates] to [TileLoadingDebugInfo] -/// -/// Used within [ValueNotifier]s, which are manually updated when a tile -/// completes loading. -typedef TileLoadingDebugMap = Map; diff --git a/lib/src/providers/tile_loading_interceptor/map_typedef.dart b/lib/src/providers/tile_loading_interceptor/map_typedef.dart new file mode 100644 index 00000000..676b09e4 --- /dev/null +++ b/lib/src/providers/tile_loading_interceptor/map_typedef.dart @@ -0,0 +1,11 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// Mapping of [TileCoordinates] to [TileLoadingInterceptorResult] +/// +/// Used within [ValueNotifier]s, which are updated when a tile completes +/// loading. +typedef TileLoadingInterceptorMap + = Map; diff --git a/lib/src/providers/tile_loading_debug/info.dart b/lib/src/providers/tile_loading_interceptor/result.dart similarity index 74% rename from lib/src/providers/tile_loading_debug/info.dart rename to lib/src/providers/tile_loading_interceptor/result.dart index 6e98cbd2..2e0ce430 100644 --- a/lib/src/providers/tile_loading_debug/info.dart +++ b/lib/src/providers/tile_loading_interceptor/result.dart @@ -6,21 +6,21 @@ part of '../../../flutter_map_tile_caching.dart'; /// Information useful to debug and record detailed statistics for the loading /// mechanisms and paths of a tile /// -/// When an object of this type is emitted through a [TileLoadingDebugMap], the -/// tile will have finished loading (successfully or unsuccessfully), and all +/// When an object of this type is emitted through a [TileLoadingInterceptorMap], +/// the tile will have finished loading (successfully or unsuccessfully), and all /// fields/properties will be initialised and safe to read. -class TileLoadingDebugInfo { - TileLoadingDebugInfo._(); +class TileLoadingInterceptorResult { + TileLoadingInterceptorResult._(); /// Indicates whether & how the tile completed loading successfully /// /// If `null`, loading was unsuccessful. Otherwise, the - /// [TileLoadingDebugResultPath] indicates the final path point of how the - /// tile was output. + /// [TileLoadingInterceptorResultPath] indicates the final path point of how + /// the tile was output. /// /// See [didComplete] for a boolean result. If `null`, see [error] for the /// error/exception object. - late final TileLoadingDebugResultPath? result; + late final TileLoadingInterceptorResultPath? resultPath; /// Indicates whether & how the tile completed loading unsuccessfully /// @@ -28,21 +28,21 @@ class TileLoadingDebugInfo { /// error/exception thrown whilst loading the tile - which is likely to be an /// [FMTCBrowsingError]. /// - /// See [didComplete] for a boolean result. If `null`, see [result] for the + /// See [didComplete] for a boolean result. If `null`, see [resultPath] for the /// exact result path. late final Object? error; /// Indicates whether the tile completed loading successfully /// - /// * `true`: completed - see [result] for exact result path + /// * `true`: completed - see [resultPath] for exact result path /// * `false`: errored - see [error] for error/exception object - bool get didComplete => result != null; + bool get didComplete => resultPath != null; /// The requested URL of the tile (based on the [TileLayer.urlTemplate]) late final String networkUrl; /// The storage-suitable UID of the tile: the result of - /// [FMTCTileProviderSettings.urlTransformer] on [networkUrl] + /// [FMTCTileProvider.urlTransformer] on [networkUrl] late final String storageSuitableUID; /// If the tile already existed, the stores that it existed in/belonged to @@ -52,7 +52,7 @@ class TileLoadingDebugInfo { /// /// Calculated with: /// - /// ```dart + /// ``` /// && /// `useOtherStoresAsFallbackOnly` && /// @@ -63,10 +63,10 @@ class TileLoadingDebugInfo { /// /// Calculated with: /// - /// ```dart + /// ``` /// && /// ( - /// 'behavior' == CacheBehavior.onlineFirst || + /// `loadingStrategy` == BrowseLoadingStrategy.onlineFirst || /// /// ) /// ``` @@ -78,6 +78,9 @@ class TileLoadingDebugInfo { /// A mapping of all stores the tile was written to, to whether that tile was /// newly created in that store (not updated) /// + /// Is a future because the result must come from an asynchronously triggered + /// database write operation. + /// /// `null` if no write operation was necessary/attempted. - late final Future>? writeResult; + late final Future>? storesWriteResult; } diff --git a/lib/src/providers/tile_loading_debug/result_path.dart b/lib/src/providers/tile_loading_interceptor/result_path.dart similarity index 65% rename from lib/src/providers/tile_loading_debug/result_path.dart rename to lib/src/providers/tile_loading_interceptor/result_path.dart index 90e399c4..a08fcb2d 100644 --- a/lib/src/providers/tile_loading_debug/result_path.dart +++ b/lib/src/providers/tile_loading_interceptor/result_path.dart @@ -4,16 +4,17 @@ part of '../../../flutter_map_tile_caching.dart'; /// Methods that a tile can complete loading successfully -enum TileLoadingDebugResultPath { +enum TileLoadingInterceptorResultPath { /// The tile was retrieved from: /// /// * the specified stores /// * the unspecified stores, if - /// [FMTCTileProviderSettings.useOtherStoresAsFallbackOnly] is `false` + /// [FMTCTileProvider.useOtherStoresAsFallbackOnly] is `false` perfectFromStores, - /// The specified [CacheBehavior] was [CacheBehavior.cacheOnly], and the tile - /// was retrieved from the cache (as a fallback) + /// The specified [BrowseLoadingStrategy] was + /// [BrowseLoadingStrategy.cacheOnly], and the tile was retrieved from the + /// cache (as a fallback) cacheOnlyFromOtherStores, /// The tile was retrieved from the cache as a fallback diff --git a/lib/src/providers/tile_provider/custom_user_agent_compat_map.dart b/lib/src/providers/tile_provider/custom_user_agent_compat_map.dart new file mode 100644 index 00000000..492cf683 --- /dev/null +++ b/lib/src/providers/tile_provider/custom_user_agent_compat_map.dart @@ -0,0 +1,34 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// Custom override of [Map] that only overrides the [MapView.putIfAbsent] +/// method, to enable injection of an identifying mark ("FMTC") +class _CustomUserAgentCompatMap extends MapView { + const _CustomUserAgentCompatMap(super.map); + + /// Modified implementation of [MapView.putIfAbsent], that overrides behaviour + /// only when [key] is "User-Agent" + /// + /// flutter_map's [TileLayer] constructor calls this method after the + /// [TileLayer.tileProvider] has been constructed to customize the + /// "User-Agent" header with `TileLayer.userAgentPackageName`. + /// This method intercepts any call with [key] equal to "User-Agent" and + /// replacement value that matches the expected format, and adds an "FMTC" + /// identifying mark. + /// + /// The identifying mark is injected to seperate traffic sent via FMTC from + /// standard flutter_map traffic, as it significantly changes the behaviour of + /// tile retrieval, and could generate more traffic. + @override + String putIfAbsent(String key, String Function() ifAbsent) { + if (key != 'User-Agent') return super.putIfAbsent(key, ifAbsent); + + final replacementValue = ifAbsent(); + if (!RegExp(r'flutter_map \(.+\)').hasMatch(replacementValue)) { + return super.putIfAbsent(key, ifAbsent); + } + return this[key] = replacementValue.replaceRange(11, 12, ' + FMTC '); + } +} diff --git a/lib/src/providers/tile_provider/behaviours.dart b/lib/src/providers/tile_provider/strategies.dart similarity index 63% rename from lib/src/providers/tile_provider/behaviours.dart rename to lib/src/providers/tile_provider/strategies.dart index dc504fa2..ce0b844d 100644 --- a/lib/src/providers/tile_provider/behaviours.dart +++ b/lib/src/providers/tile_provider/strategies.dart @@ -3,52 +3,46 @@ part of '../../../flutter_map_tile_caching.dart'; -/// Alias of [CacheBehavior] +/// Determines whether the network or cache is preferred during browse caching, +/// and how to fallback /// -/// ... with the correct spelling :D -typedef CacheBehaviour = CacheBehavior; - -/// Behaviours dictating how and when browse caching should occur -/// -/// | `CacheBehavior` | Preferred fetch method | Fallback fetch method | +/// | `BrowseLoadingStrategy` | Preferred fetch method | Fallback fetch method | /// |--------------------------|------------------------|-----------------------| /// | `cacheOnly` | Cache | None | /// | `cacheFirst` | Cache | Network | /// | `onlineFirst` | Network | Cache | /// | *Standard Tile Provider* | *Network* | *None* | -enum CacheBehavior { +enum BrowseLoadingStrategy { /// Only fetch tiles from the local cache /// - /// In this mode, [StoreReadWriteBehavior] is irrelevant. + /// In this mode, [BrowseStoreStrategy] is irrelevant. /// /// Throws [FMTCBrowsingErrorType.missingInCacheOnlyMode] if a tile is /// unavailable. /// - /// See documentation on [CacheBehavior] for behavior comparison table. + /// See documentation on [BrowseLoadingStrategy] for a strategy comparison + /// table. cacheOnly, /// Fetch tiles from the cache, falling back to the network to fetch and /// create/update non-existent/expired tiles, dependent on the selected - /// [StoreReadWriteBehavior] + /// [BrowseStoreStrategy] /// - /// See documentation on [CacheBehavior] for behavior comparison table. + /// See documentation on [BrowseLoadingStrategy] for a strategy comparison + /// table. cacheFirst, /// Fetch and create/update non-existent/expired tiles from the network, /// falling back to the cache to fetch tiles, dependent on the selected - /// [StoreReadWriteBehavior] + /// [BrowseStoreStrategy] /// - /// See documentation on [CacheBehavior] for behavior comparison table. + /// See documentation on [BrowseLoadingStrategy] for a strategy comparison + /// table. onlineFirst, } -/// Alias of [StoreReadWriteBehavior] -/// -/// ... with the correct spelling :D -typedef StoreReadWriteBehaviour = StoreReadWriteBehavior; - -/// Determines the read/update/create tile behaviour of a store -enum StoreReadWriteBehavior { +/// Determines when tiles should be written to a store during browse caching +enum BrowseStoreStrategy { /// Only read tiles read, diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index b264b482..1172a7cf 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -9,11 +9,11 @@ part of '../../../flutter_map_tile_caching.dart'; /// To use a single store, use [FMTCStore.getTileProvider]. /// /// To use multiple stores, use the [FMTCTileProvider.multipleStores] -/// constructor. See documentation on [storeNames] and [otherStoresBehavior] +/// constructor. See documentation on [storeNames] and [otherStoresStrategy] /// for information on usage. /// /// To use all stores, use the [FMTCTileProvider.allStores] constructor. See -/// documentation on [otherStoresBehavior] for information on usage. +/// documentation on [otherStoresStrategy] for information on usage. /// /// An "FMTC" identifying mark is injected into the "User-Agent" header generated /// by flutter_map, except if specified in the constructor. For technical @@ -25,17 +25,19 @@ class FMTCTileProvider extends TileProvider { /// See [FMTCTileProvider] for information FMTCTileProvider.multipleStores({ required this.storeNames, - this.otherStoresBehavior, - FMTCTileProviderSettings? settings, - this.tileLoadingDebugger, - Map? headers, + this.otherStoresStrategy, + this.loadingStrategy = BrowseLoadingStrategy.cacheFirst, + this.useOtherStoresAsFallbackOnly = false, + this.recordHitsAndMisses = true, + this.cachedValidDuration = Duration.zero, + UrlTransformer? urlTransformer, + this.errorHandler, + this.tileLoadingInterceptor, http.Client? httpClient, - }) : settings = settings ?? FMTCTileProviderSettings.instance, + Map? headers, + }) : assert(storeNames.isNotEmpty, '`storeNames` cannot be empty'), + urlTransformer = (urlTransformer ?? (u) => u), httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), - assert( - storeNames.isNotEmpty || otherStoresBehavior != null, - '`storeNames` cannot be empty if `allStoresConfiguration` is `null`', - ), super( headers: (headers?.containsKey('User-Agent') ?? false) ? headers @@ -44,29 +46,35 @@ class FMTCTileProvider extends TileProvider { /// See [FMTCTileProvider] for information FMTCTileProvider.allStores({ - required StoreReadWriteBehavior allStoresConfiguration, - FMTCTileProviderSettings? settings, - ValueNotifier? tileLoadingDebugger, - Map? headers, + required BrowseStoreStrategy allStoresStrategy, + this.loadingStrategy = BrowseLoadingStrategy.cacheFirst, + this.useOtherStoresAsFallbackOnly = false, + this.recordHitsAndMisses = true, + this.cachedValidDuration = Duration.zero, + UrlTransformer? urlTransformer, + this.errorHandler, + this.tileLoadingInterceptor, http.Client? httpClient, - }) : this.multipleStores( - storeNames: const {}, - otherStoresBehavior: allStoresConfiguration, - settings: settings, - tileLoadingDebugger: tileLoadingDebugger, - headers: headers, - httpClient: httpClient, + Map? headers, + }) : storeNames = const {}, + otherStoresStrategy = allStoresStrategy, + urlTransformer = (urlTransformer ?? (u) => u), + httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), + super( + headers: (headers?.containsKey('User-Agent') ?? false) + ? headers + : _CustomUserAgentCompatMap(headers ?? {}), ); /// The store names from which to (possibly) read/update/create tiles from/in /// - /// Keys represent store names, and the associated [StoreReadWriteBehavior] + /// Keys represent store names, and the associated [BrowseStoreStrategy] /// represents how that store should be used. /// /// Stores not included will not be used by default. However, - /// [otherStoresBehavior] determines whether & how all other unspecified + /// [otherStoresStrategy] determines whether & how all other unspecified /// stores should be used. - final Map storeNames; + final Map storeNames; /// The behaviour of all other stores not specified in [storeNames] /// @@ -75,42 +83,123 @@ class FMTCTileProvider extends TileProvider { /// Setting a non-`null` value may reduce performance, as internal queries /// will have fewer constraints and therefore be less efficient. /// - /// Also see [FMTCTileProviderSettings.useOtherStoresAsFallbackOnly] for - /// whether these unspecified stores should only be used as a last resort or - /// in addition to the specified stores as normal. - final StoreReadWriteBehavior? otherStoresBehavior; + /// Also see [useOtherStoresAsFallbackOnly] for whether these unspecified + /// stores should only be used as a last resort or in addition to the specified + /// stores as normal. + final BrowseStoreStrategy? otherStoresStrategy; - /// The tile provider settings to use + /// Determines whether the network or cache is preferred during browse + /// caching, and how to fallback /// - /// Defaults to the ambient [FMTCTileProviderSettings.instance]. - final FMTCTileProviderSettings settings; + /// Defaults to [BrowseLoadingStrategy.cacheFirst]. + final BrowseLoadingStrategy loadingStrategy; - /// [http.Client] (such as a [IOClient]) used to make all network requests + /// Whether to only use tiles retrieved by + /// [FMTCTileProvider.otherStoresStrategy] after all specified stores have + /// been exhausted (where the tile was not present) /// - /// Do not close manually. + /// When tiles are retrieved from other stores, it is counted as a miss for the + /// specified store(s). /// - /// Defaults to a standard [IOClient]/[HttpClient]. - final http.Client httpClient; + /// This may introduce notable performance reductions, especially if failures + /// occur often or the root is particularly large, as an extra lookup with + /// unbounded constraints is required for each tile. + /// + /// Defaults to `false`. + final bool useOtherStoresAsFallbackOnly; + + /// Whether to record the [StoreStats.hits] and [StoreStats.misses] statistics + /// + /// When enabled, hits will be recorded for all stores that the tile belonged + /// to and were present in [FMTCTileProvider.storeNames], when necessary. + /// Misses will be recorded for all stores specified in the tile provided, + /// where necessary + /// + /// Disable to improve performance and/or if these statistics are never used. + /// + /// Defaults to `true`. + final bool recordHitsAndMisses; + + /// The duration for which a tile does not require updating when cached, after + /// which it is marked as expired and updated at the next possible + /// opportunity + /// + /// Set to [Duration.zero] to never expire a tile (default). + final Duration cachedValidDuration; + + /// Method used to create a tile's storage-suitable UID from it's real URL + /// + /// The input string is the tile's URL. The output string should be a unique + /// string to that tile that will remain as stable as necessary if parts of the + /// URL not directly related to the tile image change. + /// + /// To store and retrieve tiles, FMTC uses a tile's storage-suitable UID. + /// When a tile is stored, the tile URL is transformed before storage. When a + /// tile is retrieved from the cache, the tile URL is transformed before + /// retrieval. + /// + /// A storage-suitable UID is usually the tile's own real URL - although it may + /// not necessarily be. The tile URL is guaranteed to refer only to that tile + /// from that server (unless the server backend changes). + /// + /// However, some parts of the tile URL should not be stored. For example, + /// an API key transmitted as part of the query parameters should not be + /// stored - and is not storage-suitable. This is because, if the API key + /// changes, the cached tile will still use the old UID containing the old API + /// key, and thus the tile will never be retrieved from storage, even if the + /// image is the same. + /// + /// [urlTransformerOmitKeyValues] may be used as a transformer to omit entire + /// key-value pairs from a URL where the key matches one of the specified keys. + /// + /// > [!IMPORTANT] + /// > The callback will be passed to a different isolate: therefore, avoid + /// > using any external state that may not be properly captured or cannot be + /// > copied to an isolate spawned with [Isolate.spawn] (see [SendPort.send]). + /// + /// _Internally, the storage-suitable UID is usually referred to as the tile + /// URL (with distinction inferred)._ + /// + /// By default, the output string is the input string - that is, the + /// storage-suitable UID is the tile's real URL. + final UrlTransformer urlTransformer; + + /// A custom callback that will be called when an [FMTCBrowsingError] is thrown + /// + /// If no value is returned, the error will be (re)thrown as normal. However, + /// if a [Uint8List], that will be displayed instead (decoded as an image), + /// and no error will be thrown. + final BrowsingExceptionHandler? errorHandler; - /// Allows debugging and advanced logging of internal tile loading mechanisms + /// Allows tracking (eg. for debugging and logging) of the internal tile + /// loading mechanisms + /// + /// For example, this could be used to debug why tiles aren't loading as + /// expected (perhaps in combination with [TileLayer.tileBuilder] & + /// [ValueListenableBuilder] as in the example app), or to perform more + /// advanced monitoring and logging than the hit & miss statistics provide. + /// + /// --- /// /// To use, first initialise a [ValueNotifier], like so, then pass it to this /// parameter: /// /// ```dart - /// final tileLoadingDebugger = ValueNotifier({}); - /// // Do not use `const {}` + /// final tileLoadingInterceptor = + /// ValueNotifier({}); // Do not use `const {}` /// ``` /// /// This notifier will be notified, and the `value` updated, every time a tile /// completes loading (successfully or unsuccessfully). The `value` maps - /// [TileCoordinates] to [TileLoadingDebugInfo]s. + /// [TileCoordinates]s to [TileLoadingInterceptorResult]s. + final ValueNotifier? tileLoadingInterceptor; + + /// [http.Client] (such as a [IOClient]) used to make all network requests /// - /// For example, this could be used to debug why tiles aren't loading as - /// expected (perhaps when used with [TileLayer.tileBuilder] & - /// [ValueListenableBuilder]), or to perform more advanced monitoring and - /// logging than the hit & miss statistics provide. - final ValueNotifier? tileLoadingDebugger; + /// Do not close manually. + /// + /// Defaults to a standard [IOClient]/[HttpClient]. + final http.Client httpClient; /// Each [Completer] is completed once the corresponding tile has finished /// loading @@ -166,62 +255,88 @@ class FMTCTileProvider extends TileProvider { /// Check whether a specified tile is cached in any of the current stores /// - /// If [storeNames] contains `null` (for example if - /// [FMTCTileProvider.allStores]) has been used, then the check is for if the - /// tile has been cached at all. - Future checkTileCached({ + /// If [otherStoresStrategy] is not `null`, then the check is for if the + /// tile has been cached in any store. + Future isTileCached({ required TileCoordinates coords, required TileLayer options, }) => FMTCBackendAccess.internal.tileExists( storeNames: _getSpecifiedStoresOrNull(), - url: settings.urlTransformer(getTileUrl(coords, options)), + url: urlTransformer(getTileUrl(coords, options)), ); + /// Removes specified key-value pairs from the specified [url] + /// + /// Both the key itself and its associated value, for each of [keys], will be + /// omitted. + /// + /// [link] connects a key to its value (defaults to '='). [delimiter] + /// seperates two different key value pairs (defaults to '&'). + /// + /// For example, the [url] 'abc=123&xyz=987' with [keys] only containing 'abc' + /// would become '&xyz=987'. In this case, if these were query parameters, it + /// is assumed the server will be able to handle a missing first query + /// parameter. + /// + /// Matching and removal is performed by a regular expression. Does not mutate + /// input [url]. [link] and [delimiter] are escaped (using [RegExp.escape]) + /// before they are used within the regular expression. + /// + /// This is not designed to be a security mechanism, and should not be relied + /// upon as such. + static String urlTransformerOmitKeyValues({ + required String url, + required Iterable keys, + String link = '=', + String delimiter = '&', + }) { + var mutableUrl = url; + for (final key in keys) { + mutableUrl = mutableUrl.replaceAll( + RegExp( + '${RegExp.escape(key)}${RegExp.escape(link)}' + '[^${RegExp.escape(delimiter)}]*', + ), + '', + ); + } + return mutableUrl; + } + /// If [storeNames] contains `null`, returns `null`, otherwise returns all /// non-null names (which cannot be empty) List? _getSpecifiedStoresOrNull() => - otherStoresBehavior != null ? null : storeNames.keys.toList(); + otherStoresStrategy != null ? null : storeNames.keys.toList(); @override bool operator ==(Object other) => identical(this, other) || (other is FMTCTileProvider && - other.storeNames == storeNames && - other.headers == headers && - other.settings == settings && - other.httpClient == httpClient); + mapEquals(other.storeNames, storeNames) && + other.otherStoresStrategy == otherStoresStrategy && + other.loadingStrategy == loadingStrategy && + other.useOtherStoresAsFallbackOnly == useOtherStoresAsFallbackOnly && + other.recordHitsAndMisses == recordHitsAndMisses && + other.cachedValidDuration == cachedValidDuration && + other.urlTransformer == urlTransformer && + other.errorHandler == errorHandler && + other.tileLoadingInterceptor == tileLoadingInterceptor && + other.httpClient == httpClient && + other.headers == headers); @override - int get hashCode => Object.hash(storeNames, settings, headers, httpClient); -} - -/// Custom override of [Map] that only overrides the [MapView.putIfAbsent] -/// method, to enable injection of an identifying mark ("FMTC") -class _CustomUserAgentCompatMap extends MapView { - const _CustomUserAgentCompatMap(super.map); - - /// Modified implementation of [MapView.putIfAbsent], that overrides behaviour - /// only when [key] is "User-Agent" - /// - /// flutter_map's [TileLayer] constructor calls this method after the - /// [TileLayer.tileProvider] has been constructed to customize the - /// "User-Agent" header with `TileLayer.userAgentPackageName`. - /// This method intercepts any call with [key] equal to "User-Agent" and - /// replacement value that matches the expected format, and adds an "FMTC" - /// identifying mark. - /// - /// The identifying mark is injected to seperate traffic sent via FMTC from - /// standard flutter_map traffic, as it significantly changes the behaviour of - /// tile retrieval, and could generate more traffic. - @override - String putIfAbsent(String key, String Function() ifAbsent) { - if (key != 'User-Agent') return super.putIfAbsent(key, ifAbsent); - - final replacementValue = ifAbsent(); - if (!RegExp(r'flutter_map \(.+\)').hasMatch(replacementValue)) { - return super.putIfAbsent(key, ifAbsent); - } - return this[key] = replacementValue.replaceRange(11, 12, ' + FMTC '); - } + int get hashCode => Object.hash( + storeNames, + otherStoresStrategy, + loadingStrategy, + useOtherStoresAsFallbackOnly, + recordHitsAndMisses, + cachedValidDuration, + urlTransformer, + errorHandler, + tileLoadingInterceptor, + httpClient, + headers, + ); } diff --git a/lib/src/providers/tile_provider/tile_provider_settings.dart b/lib/src/providers/tile_provider/tile_provider_settings.dart deleted file mode 100644 index 79812941..00000000 --- a/lib/src/providers/tile_provider/tile_provider_settings.dart +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of '../../../flutter_map_tile_caching.dart'; - -/// Settings for an [FMTCTileProvider] -/// -/// This class is a kind of singleton, which maintains a single instance, but -/// allows allows for a one-shot creation where necessary. -class FMTCTileProviderSettings { - /// Create new settings for an [FMTCTileProvider], and set the [instance] (if - /// [setInstance] is `true`, as default) - /// - /// To access the existing settings, if any, get [instance]. - factory FMTCTileProviderSettings({ - CacheBehavior behavior = CacheBehavior.cacheFirst, - Duration cachedValidDuration = const Duration(days: 16), - bool useUnspecifiedAsLastResort = false, - bool recordHitsAndMisses = true, - String Function(String)? urlTransformer, - @Deprecated( - '`obscuredQueryParams` has been deprecated in favour of `urlTransformer`, ' - 'which provides more flexibility.\n' - 'To restore similar functioning, use ' - '`FMTCTileProviderSettings.urlTransformerOmitKeyValues`. Note that this ' - 'will apply to the entire URL, not only the query part, which may have ' - 'a different behaviour in some rare cases.\n' - 'This argument will be removed in a future version.', - ) - List obscuredQueryParams = const [], - FMTCBrowsingErrorHandler? errorHandler, - bool setInstance = true, - }) { - final settings = FMTCTileProviderSettings._( - behavior: behavior, - cachedValidDuration: cachedValidDuration, - useOtherStoresAsFallbackOnly: useUnspecifiedAsLastResort, - recordHitsAndMisses: recordHitsAndMisses, - urlTransformer: urlTransformer ?? - (obscuredQueryParams.isNotEmpty - ? (url) { - final components = url.split('?'); - if (components.length == 1) return url; - return '${components[0]}?' - '${urlTransformerOmitKeyValues( - url: url, - keys: obscuredQueryParams, - )}'; - } - : (e) => e), - errorHandler: errorHandler, - ); - - if (setInstance) _instance = settings; - return settings; - } - - FMTCTileProviderSettings._({ - required this.behavior, - required this.cachedValidDuration, - required this.useOtherStoresAsFallbackOnly, - required this.recordHitsAndMisses, - required this.urlTransformer, - required this.errorHandler, - }); - - /// Get an existing instance, if one has been constructed, or get the default - /// intial configuration - static FMTCTileProviderSettings get instance => _instance; - static var _instance = FMTCTileProviderSettings(); - - /// The behaviour to use when retrieving and writing tiles when browsing - /// - /// Defaults to [CacheBehavior.cacheFirst]. - final CacheBehavior behavior; - - /// Whether to only use tiles retrieved by - /// [FMTCTileProvider.otherStoresBehavior] after all specified stores have - /// been exhausted (where the tile was not present) - /// - /// When tiles are retrieved from other stores, it is counted as a miss for the - /// specified store(s). - /// - /// This may introduce notable performance reductions, especially if failures - /// occur often or the root is particularly large, as an extra lookup with - /// unbounded constraints is required for each tile. - /// - /// Defaults to `false`. - final bool useOtherStoresAsFallbackOnly; - - /// Whether to record the [StoreStats.hits] and [StoreStats.misses] statistics - /// - /// When enabled, hits will be recorded for all stores that the tile belonged - /// to and were present in [FMTCTileProvider.storeNames], when necessary. - /// Misses will be recorded for all stores specified in the tile provided, - /// where necessary - /// - /// Disable to improve performance and/or if these statistics are never used. - /// - /// Defaults to `true`. - final bool recordHitsAndMisses; - - /// The duration until a tile expires and needs to be fetched again when - /// browsing. Also called `validDuration`. - /// - /// Defaults to 16 days, set to [Duration.zero] to disable. - final Duration cachedValidDuration; - - /// Method used to create a tile's storage-suitable UID from it's real URL - /// - /// The input string is the tile's URL. The output string should be a unique - /// string to that tile that will remain as stable as necessary if parts of the - /// URL not directly related to the tile image change. - /// - /// To store and retrieve tiles, FMTC uses a tile's storage-suitable UID. - /// When a tile is stored, the tile URL is transformed before storage. When a - /// tile is retrieved from the cache, the tile URL is transformed before - /// retrieval. - /// - /// A storage-suitable UID is usually the tile's own real URL - although it may - /// not necessarily be. The tile URL is guaranteed to refer only to that tile - /// from that server (unless the server backend changes). - /// - /// However, some parts of the tile URL should not be stored. For example, - /// an API key transmitted as part of the query parameters should not be - /// stored - and is not storage-suitable. This is because, if the API key - /// changes, the cached tile will still use the old UID containing the old API - /// key, and thus the tile will never be retrieved from storage, even if the - /// image is the same. - /// - /// [FMTCTileProviderSettings.urlTransformerOmitKeyValues] may be used as a - /// transformer to omit entire key-value pairs from a URL where the key matches - /// one of the specified keys. - /// - /// > [!IMPORTANT] - /// > The callback should be **stateless** and **self-contained**. That is, - /// > the callback should not depend on any other tile or other state that is - /// > in memory only, and it should not use nor store any state externally or - /// > from any other scope (with the exception of the argument). This callback - /// > will be transferred to a seperate isolate when downloading, and therefore - /// > these external dependencies may not work as expected, at all, or be in - /// > the expected state. - /// - /// _Internally, the storage-suitable UID is usually referred to as the tile - /// URL (with distinction inferred)._ - /// - /// By default, the output string is the input string - that is, the - /// storage-suitable UID is the tile's real URL. - final String Function(String) urlTransformer; - - /// A custom callback that will be called when an [FMTCBrowsingError] is raised - /// - /// If no value is returned, the error will be (re)thrown as normal. However, - /// if a [Uint8List], that will be displayed instead (decoded as an image), - /// and no error will be thrown. - final FMTCBrowsingErrorHandler? errorHandler; - - /// Removes specified key-value pairs from the specified [url] - /// - /// Both the key itself and its associated value, for each of [keys], will be - /// omitted. - /// - /// [link] connects a key to its value (defaults to '='). [delimiter] - /// seperates two different key value pairs (defaults to '&'). - /// - /// For example, the [url] 'abc=123&xyz=987' with [keys] only containing 'abc' - /// would become '&xyz=987'. In this case, if these were query parameters, it - /// is assumed the server will be able to handle a missing first query - /// parameter. - /// - /// Matching and removal is performed by a regular expression. Does not mutate - /// input [url]. [link] and [delimiter] are escaped (using [RegExp.escape]) - /// before they are used within the regular expression. - /// - /// This is not designed to be a security mechanism, and should not be relied - /// upon as such. - static String urlTransformerOmitKeyValues({ - required String url, - required Iterable keys, - String link = '=', - String delimiter = '&', - }) { - var mutableUrl = url; - for (final key in keys) { - mutableUrl = mutableUrl.replaceAll( - RegExp( - '${RegExp.escape(key)}${RegExp.escape(link)}' - '[^${RegExp.escape(delimiter)}]*', - ), - '', - ); - } - return mutableUrl; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is FMTCTileProviderSettings && - other.behavior == behavior && - other.cachedValidDuration == cachedValidDuration && - other.useOtherStoresAsFallbackOnly == useOtherStoresAsFallbackOnly && - other.recordHitsAndMisses == recordHitsAndMisses && - other.urlTransformer == other.urlTransformer && - other.errorHandler == errorHandler); - - @override - int get hashCode => Object.hashAllUnordered([ - behavior, - cachedValidDuration, - useOtherStoresAsFallbackOnly, - recordHitsAndMisses, - urlTransformer, - errorHandler, - ]); -} - -/// Callback type that takes an [FMTCBrowsingError] exception -typedef FMTCBrowsingErrorHandler = Uint8List? Function( - FMTCBrowsingError exception, -); diff --git a/lib/src/providers/tile_provider/typedefs.dart b/lib/src/providers/tile_provider/typedefs.dart new file mode 100644 index 00000000..ddc60640 --- /dev/null +++ b/lib/src/providers/tile_provider/typedefs.dart @@ -0,0 +1,12 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// Callback type for [FMTCTileProvider.urlTransformer] +typedef UrlTransformer = String Function(String); + +/// Callback type for [FMTCTileProvider.errorHandler] +typedef BrowsingExceptionHandler = Uint8List? Function( + FMTCBrowsingError exception, +); diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 2dcdeefa..8635af99 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -103,9 +103,8 @@ class StoreDownload { /// /// --- /// - /// For information about [urlTransformer], see the documentation on - /// [FMTCTileProviderSettings.urlTransformer]. Will default to the value in - /// the default [FMTCTileProviderSettings], else the identity function. + /// For info about [urlTransformer], see [FMTCTileProvider.urlTransformer]. + /// Defaults to the identity function. /// /// To set additional headers, set it via [TileProvider.headers] when /// constructing the [DownloadableRegion]. @@ -124,16 +123,6 @@ class StoreDownload { Duration? maxReportInterval = const Duration(seconds: 1), bool disableRecovery = false, String Function(String)? urlTransformer, - @Deprecated( - '`obscuredQueryParams` has been deprecated in favour of `urlTransformer`, ' - 'which provides more flexibility.\n' - 'To restore similar functioning, use ' - '`FMTCTileProviderSettings.urlTransformerOmitKeyValues`. Note that this ' - 'will apply to the entire URL, not only the query part, which may have ' - 'a different behaviour in some rare cases.\n' - 'This argument will be removed in a future version.', - ) - List? obscuredQueryParams, Object instanceId = 0, }) async* { FMTCBackendAccess.internal; // Verify intialisation @@ -201,18 +190,7 @@ class StoreDownload { skipSeaTiles: skipSeaTiles, maxReportInterval: maxReportInterval, rateLimit: rateLimit, - urlTransformer: urlTransformer ?? - ((obscuredQueryParams?.isNotEmpty ?? false) - ? (url) { - final components = url.split('?'); - if (components.length == 1) return url; - return '${components[0]}?' - '${FMTCTileProviderSettings.urlTransformerOmitKeyValues( - url: url, - keys: obscuredQueryParams!, - )}'; - } - : FMTCTileProviderSettings.instance.urlTransformer), + urlTransformer: urlTransformer ?? (url) => url, recoveryId: recoveryId, backend: FMTCBackendAccessThreadSafe.internal, ), diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index 36131b54..d5730e7a 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -37,28 +37,33 @@ class FMTCStore { /// Provides bulk downloading functionality StoreDownload get download => StoreDownload._(storeName); - /// Generate a [TileProvider] that connects to FMTC internals - /// - /// [settings] defaults to the current ambient - /// [FMTCTileProviderSettings.instance], which defaults to the initial - /// configuration if no other instance has been set. + /// Generate an [FMTCTileProvider] that only specifies this store /// /// See other available [FMTCTileProvider] contructors to use multiple stores - /// at once. + /// at once. See [FMTCTileProvider] for more info. FMTCTileProvider getTileProvider({ - StoreReadWriteBehavior readWriteBehavior = - StoreReadWriteBehavior.readUpdateCreate, - StoreReadWriteBehavior? otherStoresBehavior, - FMTCTileProviderSettings? settings, - ValueNotifier? tileLoadingDebugger, + BrowseStoreStrategy storeStrategy = BrowseStoreStrategy.readUpdateCreate, + BrowseStoreStrategy? otherStoresStrategy, + BrowseLoadingStrategy loadingStrategy = BrowseLoadingStrategy.cacheFirst, + bool useOtherStoresAsFallbackOnly = false, + bool recordHitsAndMisses = true, + Duration cachedValidDuration = Duration.zero, + UrlTransformer? urlTransformer, + BrowsingExceptionHandler? errorHandler, + ValueNotifier? tileLoadingInterceptor, Map? headers, http.Client? httpClient, }) => FMTCTileProvider.multipleStores( - storeNames: {storeName: readWriteBehavior}, - otherStoresBehavior: otherStoresBehavior, - settings: settings, - tileLoadingDebugger: tileLoadingDebugger, + storeNames: {storeName: storeStrategy}, + otherStoresStrategy: otherStoresStrategy, + loadingStrategy: loadingStrategy, + useOtherStoresAsFallbackOnly: useOtherStoresAsFallbackOnly, + recordHitsAndMisses: recordHitsAndMisses, + cachedValidDuration: cachedValidDuration, + urlTransformer: urlTransformer, + errorHandler: errorHandler, + tileLoadingInterceptor: tileLoadingInterceptor, headers: headers, httpClient: httpClient, ); diff --git a/pubspec.yaml b/pubspec.yaml index 591e83a1..c0fd316e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 10.0.0-dev.3 +version: 10.0.0-dev.4 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues From 503cfef154bf69588785ca7daf061535d2c3dd46 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 9 Aug 2024 16:14:13 +0200 Subject: [PATCH 32/97] Minor documentation improvements --- .../image_provider/image_provider.dart | 40 +----------- .../providers/tile_provider/strategies.dart | 2 +- .../tile_provider/tile_provider.dart | 64 +++++++++++++------ 3 files changed, 46 insertions(+), 60 deletions(-) diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index 0d7eb523..9375f90b 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -68,45 +68,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { ); } - /// {@template fmtc.imageProvider.getBytes} - /// Use FMTC's caching logic to get the bytes of the specific tile (at - /// [coords]) with the specified [TileLayer] options and [FMTCTileProvider] - /// provider - /// - /// Used internally by [_FMTCImageProvider.loadImage]. [loadImage] provides - /// a decoding wrapper, but is only suitable for codecs Flutter can render. - /// - /// Therefore, this method does not make any assumptions about the format - /// of the bytes, and it is up to the user to decode/render appropriately. - /// For example, this could be incorporated into another [ImageProvider] (via - /// a [TileProvider]) to integrate FMTC caching for vector tiles. - /// - /// --- - /// - /// [key] is used to control the [ImageCache], and should be set when in a - /// context where [ImageProvider.obtainKey] is available. - /// - /// [chunkEvents] is used to improve the quality of an [ImageProvider], and - /// should be set when [MultiFrameImageStreamCompleter] is in use inside an - /// [ImageProvider.loadImage]. Note that it will be closed by this method. - /// - /// [startedLoading] & [finishedLoadingBytes] are used to indicate to - /// flutter_map when it is safe to dispose a [TileProvider], and should be set - /// when used inside a [TileProvider]'s context (such as directly or within - /// a dedicated [ImageProvider]). - /// - /// [requireValidImage] is `false` by default, but should be `true` when - /// only Flutter decodable data is being used (ie. most raster tiles) (and is - /// set `true` when used by [loadImage] internally). This provides an extra - /// layer of protection by preventing invalid data from being stored inside - /// the cache, which could cause further issues at a later point. However, this - /// may be set `false` intentionally, for example to allow for vector tiles - /// to be stored. If this is `true`, and the image is invalid, an - /// [FMTCBrowsingError] with sub-category - /// [FMTCBrowsingErrorType.invalidImageData] will be thrown - if `false`, then - /// FMTC will not throw an error, but Flutter will if the bytes are attempted - /// to be decoded (now or at a later time). - /// {@endtemplate} + /// {@macro fmtc.imageProvider.getBytes} static Future getBytes({ required TileCoordinates coords, required TileLayer options, diff --git a/lib/src/providers/tile_provider/strategies.dart b/lib/src/providers/tile_provider/strategies.dart index ce0b844d..69491582 100644 --- a/lib/src/providers/tile_provider/strategies.dart +++ b/lib/src/providers/tile_provider/strategies.dart @@ -6,7 +6,7 @@ part of '../../../flutter_map_tile_caching.dart'; /// Determines whether the network or cache is preferred during browse caching, /// and how to fallback /// -/// | `BrowseLoadingStrategy` | Preferred fetch method | Fallback fetch method | +/// | `BrowseLoadingStrategy` | Preferred method | Fallback method | /// |--------------------------|------------------------|-----------------------| /// | `cacheOnly` | Cache | None | /// | `cacheFirst` | Cache | Network | diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index 1172a7cf..50b45a4f 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -133,21 +133,8 @@ class FMTCTileProvider extends TileProvider { /// string to that tile that will remain as stable as necessary if parts of the /// URL not directly related to the tile image change. /// - /// To store and retrieve tiles, FMTC uses a tile's storage-suitable UID. - /// When a tile is stored, the tile URL is transformed before storage. When a - /// tile is retrieved from the cache, the tile URL is transformed before - /// retrieval. - /// - /// A storage-suitable UID is usually the tile's own real URL - although it may - /// not necessarily be. The tile URL is guaranteed to refer only to that tile - /// from that server (unless the server backend changes). - /// - /// However, some parts of the tile URL should not be stored. For example, - /// an API key transmitted as part of the query parameters should not be - /// stored - and is not storage-suitable. This is because, if the API key - /// changes, the cached tile will still use the old UID containing the old API - /// key, and thus the tile will never be retrieved from storage, even if the - /// image is the same. + /// For more information, see: + /// . /// /// [urlTransformerOmitKeyValues] may be used as a transformer to omit entire /// key-value pairs from a URL where the key matches one of the specified keys. @@ -232,7 +219,45 @@ class FMTCTileProvider extends TileProvider { super.dispose(); } - /// {@macro fmtc.imageProvider.getBytes} + /// {@template fmtc.imageProvider.getBytes} + /// Use FMTC's caching logic to get the bytes of the specific tile (at + /// [coords]) with the specified [TileLayer] options and [FMTCTileProvider] + /// provider + /// + /// Used internally by [_FMTCImageProvider.loadImage]. `loadImage` provides + /// a decoding wrapper, but is only suitable for codecs Flutter can render. + /// + /// Therefore, this method does not make any assumptions about the format + /// of the bytes, and it is up to the user to decode/render appropriately. + /// For example, this could be incorporated into another [ImageProvider] (via + /// a [TileProvider]) to integrate FMTC caching for vector tiles. + /// + /// --- + /// + /// [key] is used to control the [ImageCache], and should be set when in a + /// context where [ImageProvider.obtainKey] is available. + /// + /// [chunkEvents] is used to improve the quality of an [ImageProvider], and + /// should be set when [MultiFrameImageStreamCompleter] is in use inside an + /// [ImageProvider.loadImage]. Note that it will be closed by this method. + /// + /// [startedLoading] & [finishedLoadingBytes] are used to indicate to + /// flutter_map when it is safe to dispose a [TileProvider], and should be set + /// when used inside a [TileProvider]'s context (such as directly or within + /// a dedicated [ImageProvider]). + /// + /// [requireValidImage] is `false` by default, but should be `true` when + /// only Flutter decodable data is being used (ie. most raster tiles) (and is + /// set `true` when used by `loadImage` internally). This provides an extra + /// layer of protection by preventing invalid data from being stored inside + /// the cache, which could cause further issues at a later point. However, this + /// may be set `false` intentionally, for example to allow for vector tiles + /// to be stored. If this is `true`, and the image is invalid, an + /// [FMTCBrowsingError] with sub-category + /// [FMTCBrowsingErrorType.invalidImageData] will be thrown - if `false`, then + /// FMTC will not throw an error, but Flutter will if the bytes are attempted + /// to be decoded (now or at a later time). + /// {@endtemplate} Future getBytes({ required TileCoordinates coords, required TileLayer options, @@ -266,10 +291,7 @@ class FMTCTileProvider extends TileProvider { url: urlTransformer(getTileUrl(coords, options)), ); - /// Removes specified key-value pairs from the specified [url] - /// - /// Both the key itself and its associated value, for each of [keys], will be - /// omitted. + /// Removes key-value pairs from the specified [url], given only the [keys] /// /// [link] connects a key to its value (defaults to '='). [delimiter] /// seperates two different key value pairs (defaults to '&'). @@ -285,6 +307,8 @@ class FMTCTileProvider extends TileProvider { /// /// This is not designed to be a security mechanism, and should not be relied /// upon as such. + /// + /// See [urlTransformer] for more information. static String urlTransformerOmitKeyValues({ required String url, required Iterable keys, From 3dcdfe5136bdeabcd515a974a2ae5cb5c58f3385 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 11 Aug 2024 23:54:52 +0100 Subject: [PATCH 33/97] Added export functionality to example app Added root stats display to example app --- example/lib/main.dart | 16 +- ...lumn_headers_and_inheritable_settings.dart | 6 +- .../components/export_stores_button.dart | 181 ++++++++++++++++++ .../panels/stores/components/root_tile.dart | 95 +++++++++ .../panels/stores/components/store_tile.dart | 167 +++++++++++----- .../state/export_selection_provider.dart | 20 ++ .../panels/stores/stores_list.dart | 41 +++- example/pubspec.yaml | 2 +- 8 files changed, 460 insertions(+), 68 deletions(-) create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/export_stores_button.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/root_tile.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/state/export_selection_provider.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index fdb0445f..00b102a3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -4,6 +4,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'src/screens/home/config_view/panels/stores/state/export_selection_provider.dart'; import 'src/screens/home/home.dart'; import 'src/screens/home/map_view/state/region_selection_provider.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; @@ -110,22 +111,13 @@ class _AppContainer extends StatelessWidget { ChangeNotifierProvider( create: (_) => GeneralProvider(), ), - /*ChangeNotifierProvider( - create: (_) => MapProvider(), - lazy: true, - ),*/ - ChangeNotifierProvider( - create: (_) => RegionSelectionProvider(), - lazy: true, - ), /* ChangeNotifierProvider( - create: (_) => ConfigureDownloadProvider(), - lazy: true, + create: (_) => ExportSelectionProvider(), ), ChangeNotifierProvider( - create: (_) => DownloadingProvider(), + create: (_) => RegionSelectionProvider(), lazy: true, - ),*/ + ), ], child: MaterialApp( title: 'FMTC Demo', diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart b/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart index e3db893e..42476123 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart @@ -85,11 +85,7 @@ class ColumnHeadersAndInheritableSettings extends StatelessWidget { ), ), ), - const Divider( - height: 8, - indent: 24, - endIndent: 24, - ), + const Divider(height: 8, indent: 12, endIndent: 12), ], ); } diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores_button.dart b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores_button.dart new file mode 100644 index 00000000..67ca8d5b --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores_button.dart @@ -0,0 +1,181 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; + +import '../state/export_selection_provider.dart'; + +class ExportStoresButton extends StatelessWidget { + const ExportStoresButton({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: SizedBox( + height: double.infinity, + child: FilledButton.tonalIcon( + label: const Text('Export selected stores'), + icon: const Icon(Icons.send_and_archive), + onPressed: () => _export(context), + ), + ), + ), + const SizedBox(width: 8), + IconButton.outlined( + icon: const Icon(Icons.cancel), + tooltip: 'Cancel export', + onPressed: () => context + .read() + .clearSelectedStores(), + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + 'Within the example app, for simplicity, each store contains ' + 'tiles from a single URL template. This is not a limitation ' + 'with FMTC.\nAdditionally, FMTC supports changing the ' + 'read/write behaviour for all unspecified stores, but this ' + 'is not represented wihtin this app.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ); + + Future _export(BuildContext context) async { + Future showOverwriteConfirmationDialog(BuildContext context) => + showDialog( + context: context, + builder: (context) => AlertDialog.adaptive( + title: const Text( + 'Overwrite existing file?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Overwrite'), + ), + ], + ), + ); + + final provider = context.read(); + final fileNameTime = + DateTime.now().toString().split('.').first.replaceAll(':', '-'); + + late final String filePath; + if (Platform.isAndroid || Platform.isIOS) { + final dirPath = await FilePicker.platform.getDirectoryPath(); + if (dirPath == null) return; + filePath = p.join( + dirPath, + 'export ($fileNameTime).fmtc', + ); + } else { + final intermediateFilePath = await FilePicker.platform.saveFile( + dialogTitle: 'Export Stores', + fileName: 'export ($fileNameTime).fmtc', + type: FileType.custom, + allowedExtensions: ['fmtc'], + ); + if (intermediateFilePath == null) return; + filePath = intermediateFilePath; + } + + final selectedType = await FileSystemEntity.type(filePath); + + if (!context.mounted) { + provider.clearSelectedStores(); + return; + } + + const invalidTypeSnackbar = SnackBar( + content: Text( + 'Cannot start export: must be a file or non-existent', + ), + ); + + switch (selectedType) { + case FileSystemEntityType.notFound: + break; + case FileSystemEntityType.directory: + case FileSystemEntityType.link: + case FileSystemEntityType.pipe: + case FileSystemEntityType.unixDomainSock: + ScaffoldMessenger.maybeOf(context)?.showSnackBar(invalidTypeSnackbar); + return; + case FileSystemEntityType.file: + if ((Platform.isAndroid || Platform.isIOS) && + (await showOverwriteConfirmationDialog(context) ?? false)) return; + } + + if (!context.mounted) { + provider.clearSelectedStores(); + return; + } + + unawaited( + showDialog( + context: context, + builder: (context) => const _ExportingProgressDialog(), + barrierDismissible: false, + ), + ); + + final startTime = DateTime.timestamp(); + await FMTCRoot.external(pathToArchive: filePath) + .export(storeNames: provider.selectedStores); + + provider.clearSelectedStores(); + + if (!context.mounted) return; + Navigator.of(context).pop(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar( + content: Text( + 'Export complete (in ${DateTime.timestamp().difference(startTime)})', + ), + ), + ); + } +} + +class _ExportingProgressDialog extends StatelessWidget { + const _ExportingProgressDialog(); + + @override + Widget build(BuildContext context) => const AlertDialog.adaptive( + icon: Icon(Icons.send_and_archive), + title: Text('Export in progress'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator.adaptive(), + SizedBox(height: 12), + Text( + "Please don't close this dialog or leave the app.\nThe operation " + "will continue if the dialog is closed.\nWe'll let you know once " + "we're done.", + textAlign: TextAlign.center, + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/root_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/root_tile.dart new file mode 100644 index 00000000..c3d0f1b4 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/root_tile.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class RootTile extends StatefulWidget { + const RootTile({ + super.key, + required this.length, + required this.size, + required this.realSizeAdditional, + }); + + final Future length; + final Future size; + final Future realSizeAdditional; + + @override + State createState() => _RootTileState(); +} + +class _RootTileState extends State { + @override + Widget build(BuildContext context) => RepaintBoundary( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: ListTile( + title: const Text( + 'Root', + style: TextStyle(fontStyle: FontStyle.italic), + ), + leading: const SizedBox.square( + dimension: 48, + child: Icon(Icons.language, size: 28), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _StatsDisplay( + stat: widget.length, + description: 'tiles', + ), + const SizedBox(width: 16), + _StatsDisplay( + stat: widget.size, + description: 'size', + ), + const SizedBox(width: 16), + _StatsDisplay( + stat: widget.realSizeAdditional, + description: 'db size', + ), + ], + ), + ), + ), + ); +} + +class _StatsDisplay extends StatelessWidget { + const _StatsDisplay({ + required this.stat, + required this.description, + }); + + final Future stat; + final String description; + + @override + Widget build(BuildContext context) => FittedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FutureBuilder( + future: stat, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const CircularProgressIndicator.adaptive(); + } + return Text( + snapshot.data!, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ); + }, + ), + Text( + description, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart index 143efc54..f49fc3dd 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart @@ -8,6 +8,7 @@ import '../../../../../../shared/misc/exts/size_formatter.dart'; import '../../../../../../shared/misc/store_metadata_keys.dart'; import '../../../../../../shared/state/general_provider.dart'; import '../../../../../store_editor/store_editor.dart'; +import '../state/export_selection_provider.dart'; import 'store_read_write_behaviour_selector.dart'; class StoreTile extends StatefulWidget { @@ -35,60 +36,78 @@ class _StoreTileState extends State { Timer? _toolsAutoHiderTimer; @override - Widget build(BuildContext context) => Material( + Widget build(BuildContext context) { + final storeName = widget.store.storeName; + + return RepaintBoundary( + child: Material( color: Colors.transparent, - child: Consumer( - builder: (context, provider, _) => FutureBuilder( + child: Consumer2( + builder: (context, provider, exportSelectionProvider, _) => + FutureBuilder( future: widget.metadata, builder: (context, metadataSnapshot) { final matchesUrl = metadataSnapshot.data != null && provider.urlTemplate == metadataSnapshot.data![StoreMetadataKeys.urlTemplate.key]; - final toolsChildren = _toolsDeleteLoading - ? [ - const Center( - child: SizedBox.square( - dimension: 25, - child: Center( - child: CircularProgressIndicator.adaptive(), - ), - ), - ), - ] - : [ - IconButton( - onPressed: _editStore, - icon: const Icon(Icons.edit), - ), - if (_toolsEmptyLoading) - const Center( - child: SizedBox.square( - dimension: 25, - child: Center( - child: CircularProgressIndicator.adaptive(), - ), - ), - ) - else - IconButton( - onPressed: _emptyStore, - icon: const Icon(Icons.delete), - ), - IconButton( - onPressed: _deleteStore, - icon: const Icon( - Icons.delete_forever, - color: Colors.red, - ), + final toolsChildren = [ + IconButton( + onPressed: _exportStore, + icon: const Icon(Icons.send_and_archive), + ), + IconButton( + onPressed: _editStore, + icon: const Icon(Icons.edit), + ), + if (_toolsEmptyLoading) + const Center( + child: SizedBox.square( + dimension: 25, + child: Center( + child: CircularProgressIndicator.adaptive(), ), - ]; + ), + ) + else + IconButton( + onPressed: _emptyStore, + icon: const Icon(Icons.delete), + ), + IconButton( + onPressed: _deleteStore, + icon: const Icon( + Icons.delete_forever, + color: Colors.red, + ), + ), + ]; + + final exportModeChildren = [ + const Icon(Icons.note_add), + const SizedBox(width: 12), + Checkbox.adaptive( + value: exportSelectionProvider.selectedStores + .contains(storeName), + onChanged: (v) { + if (v!) { + context + .read() + .addSelectedStore(storeName); + } else if (!v) { + context + .read() + .removeSelectedStore(storeName); + } + }, + ), + ]; return InkWell( onSecondaryTap: _showTools, child: ListTile( title: Text( - widget.store.storeName, + storeName, maxLines: 1, overflow: TextOverflow.fade, softWrap: false, @@ -180,10 +199,57 @@ class _StoreTileState extends State { .surfaceDim, borderRadius: BorderRadius.circular(12), ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: toolsChildren, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + ), + child: _toolsDeleteLoading + ? const Center( + child: SizedBox.square( + dimension: 25, + child: Center( + child: CircularProgressIndicator + .adaptive(), + ), + ), + ) + : Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: toolsChildren, + ), + ), + ), + ), + ), + ), + AnimatedOpacity( + opacity: exportSelectionProvider + .selectedStores.isNotEmpty + ? 1 + : 0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: exportSelectionProvider + .selectedStores.isEmpty, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceDim, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: exportModeChildren, + ), ), ), ), @@ -200,7 +266,16 @@ class _StoreTileState extends State { }, ), ), - ); + ), + ); + } + + Future _exportStore() async { + context + .read() + .addSelectedStore(widget.store.storeName); + await _hideTools(); + } Future _editStore() async { await Navigator.of(context).pushNamed( diff --git a/example/lib/src/screens/home/config_view/panels/stores/state/export_selection_provider.dart b/example/lib/src/screens/home/config_view/panels/stores/state/export_selection_provider.dart new file mode 100644 index 00000000..0098b15a --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/state/export_selection_provider.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; + +class ExportSelectionProvider extends ChangeNotifier { + final List _selectedStores = []; + List get selectedStores => _selectedStores; + void addSelectedStore(String storeName) { + _selectedStores.add(storeName); + notifyListeners(); + } + + void removeSelectedStore(String storeName) { + _selectedStores.remove(storeName); + notifyListeners(); + } + + void clearSelectedStores() { + _selectedStores.clear(); + notifyListeners(); + } +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart index b868dab4..3ca2ca5b 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; +import '../../../../../shared/misc/exts/size_formatter.dart'; import 'components/column_headers_and_inheritable_settings.dart'; +import 'components/export_stores_button.dart'; import 'components/new_store_button.dart'; import 'components/no_stores.dart'; +import 'components/root_tile.dart'; import 'components/store_tile.dart'; +import 'state/export_selection_provider.dart'; class StoresList extends StatefulWidget { const StoresList({ @@ -16,9 +21,20 @@ class StoresList extends StatefulWidget { } class _StoresListState extends State { + late Future _rootLength; + late Future _rootSize; + late Future _rootRealSizeAdditional; + late final storesStream = FMTCRoot.stats.watchStores(triggerImmediately: true).asyncMap( (_) async { + _rootLength = FMTCRoot.stats.length.then((e) => e.toString()); + final size = FMTCRoot.stats.size; + _rootSize = size.then((e) => (e * 1024).asReadableSize); + _rootRealSizeAdditional = (FMTCRoot.stats.realSize, size) + .wait + .then((e) => '+${((e.$1 - e.$2) * 1024).asReadableSize}'); + final stores = await FMTCRoot.stats.storesAvailable; return { for (final store in stores) @@ -49,13 +65,27 @@ class _StoresListState extends State { if (stores.isEmpty) return const NoStores(); return SliverList.separated( - itemCount: stores.length + 2, + itemCount: stores.length + 3, itemBuilder: (context, index) { if (index == 0) { return const ColumnHeadersAndInheritableSettings(); } if (index - 1 == stores.length) { - return const NewStoreButton(); + return RootTile( + length: _rootLength, + size: _rootSize, + realSizeAdditional: _rootRealSizeAdditional, + ); + } + if (index - 2 == stores.length) { + return Builder( + builder: (context) => + context.select( + (p) => p.selectedStores.isEmpty, + ) + ? const NewStoreButton() + : const ExportStoresButton(), + ); } final store = stores.keys.elementAt(index - 1); @@ -64,15 +94,18 @@ class _StoresListState extends State { final tileImage = stores.values.elementAt(index - 1).tileImage; return StoreTile( + key: ValueKey(store), store: store, stats: stats, metadata: metadata, tileImage: tileImage, ); }, - separatorBuilder: (context, index) => index - 1 == stores.length - 1 + separatorBuilder: (context, index) => index - 2 == stores.length - 1 ? const Divider() - : const SizedBox.shrink(), + : index - 1 == stores.length - 1 + ? const Divider(height: 8, indent: 12, endIndent: 12) + : const SizedBox.shrink(), ); }, ); diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 7fae26a3..6a1db9b0 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: better_open_file: ^3.6.5 collection: ^1.18.0 dart_earcut: ^1.1.0 - file_picker: ^8.0.6 + file_picker: ^8.0.0+1 flutter: sdk: flutter flutter_map: ^7.0.2 From 348aac3022c72ebfd75a7ab502aeb51dafecc7bd Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 12 Aug 2024 16:32:46 +0100 Subject: [PATCH 34/97] Improved exporting & importing flows Fixed exporting flow in example app on Android & iOS --- example/android/app/build.gradle | 10 ++ .../button.dart} | 110 +++++++++--------- .../export_stores/name_input_dialog.dart | 79 +++++++++++++ .../export_stores/progress_dialog.dart | 24 ++++ .../panels/stores/components/store_tile.dart | 52 +++++---- .../panels/stores/stores_list.dart | 2 +- example/pubspec.yaml | 3 +- .../flutter/generated_plugin_registrant.cc | 6 + .../windows/flutter/generated_plugins.cmake | 2 + .../impls/objectbox/backend/backend.dart | 1 - .../impls/objectbox/backend/errors.dart | 24 ---- .../internal_workers/standard/worker.dart | 81 ++++++++----- lib/src/root/external.dart | 46 ++++++-- 13 files changed, 301 insertions(+), 139 deletions(-) rename example/lib/src/screens/home/config_view/panels/stores/components/{export_stores_button.dart => export_stores/button.dart} (69%) create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/export_stores/name_input_dialog.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/export_stores/progress_dialog.dart delete mode 100644 lib/src/backend/impls/objectbox/backend/errors.dart diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 8e9ee46f..a7ca49f1 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -51,3 +51,13 @@ android { flutter { source = "../.." } + +configurations.all { + resolutionStrategy { + eachDependency { + if ((requested.group == "org.jetbrains.kotlin") && (requested.name.startsWith("kotlin-stdlib"))) { + useVersion("1.7.10") + } + } + } +} \ No newline at end of file diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores_button.dart b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart similarity index 69% rename from example/lib/src/screens/home/config_view/panels/stores/components/export_stores_button.dart rename to example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart index 67ca8d5b..3c9fa57a 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores_button.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart @@ -5,9 +5,14 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; -import '../state/export_selection_provider.dart'; +import '../../state/export_selection_provider.dart'; + +part 'name_input_dialog.dart'; +part 'progress_dialog.dart'; class ExportStoresButton extends StatelessWidget { const ExportStoresButton({super.key}); @@ -81,13 +86,29 @@ class ExportStoresButton extends StatelessWidget { DateTime.now().toString().split('.').first.replaceAll(':', '-'); late final String filePath; + late final String tempDir; if (Platform.isAndroid || Platform.isIOS) { - final dirPath = await FilePicker.platform.getDirectoryPath(); - if (dirPath == null) return; - filePath = p.join( - dirPath, - 'export ($fileNameTime).fmtc', + tempDir = p.join( + (await getTemporaryDirectory()).absolute.path, + 'fmtc_export', + ); + await Directory(tempDir).create(recursive: true); + + if (!context.mounted) { + provider.clearSelectedStores(); + return; + } + + final name = await showDialog( + context: context, + builder: (context) => _ExportingNameInputDialog( + defaultName: 'export ($fileNameTime)', + tempDir: tempDir, + ), ); + if (name == null) return; + + filePath = p.join(tempDir, '$name.fmtc'); } else { final intermediateFilePath = await FilePicker.platform.saveFile( dialogTitle: 'Export Stores', @@ -95,35 +116,36 @@ class ExportStoresButton extends StatelessWidget { type: FileType.custom, allowedExtensions: ['fmtc'], ); + if (intermediateFilePath == null) return; - filePath = intermediateFilePath; - } + final selectedType = await FileSystemEntity.type(intermediateFilePath); - final selectedType = await FileSystemEntity.type(filePath); + if (!context.mounted) { + provider.clearSelectedStores(); + return; + } - if (!context.mounted) { - provider.clearSelectedStores(); - return; - } + const invalidTypeSnackbar = SnackBar( + content: Text( + 'Cannot start export: must be a file or non-existent', + ), + ); - const invalidTypeSnackbar = SnackBar( - content: Text( - 'Cannot start export: must be a file or non-existent', - ), - ); + switch (selectedType) { + case FileSystemEntityType.notFound: + break; + case FileSystemEntityType.directory: + case FileSystemEntityType.link: + case FileSystemEntityType.pipe: + case FileSystemEntityType.unixDomainSock: + ScaffoldMessenger.maybeOf(context)?.showSnackBar(invalidTypeSnackbar); + return; + case FileSystemEntityType.file: + if ((Platform.isAndroid || Platform.isIOS) && + (await showOverwriteConfirmationDialog(context) ?? false)) return; + } - switch (selectedType) { - case FileSystemEntityType.notFound: - break; - case FileSystemEntityType.directory: - case FileSystemEntityType.link: - case FileSystemEntityType.pipe: - case FileSystemEntityType.unixDomainSock: - ScaffoldMessenger.maybeOf(context)?.showSnackBar(invalidTypeSnackbar); - return; - case FileSystemEntityType.file: - if ((Platform.isAndroid || Platform.isIOS) && - (await showOverwriteConfirmationDialog(context) ?? false)) return; + filePath = intermediateFilePath; } if (!context.mounted) { @@ -154,28 +176,10 @@ class ExportStoresButton extends StatelessWidget { ), ), ); - } -} - -class _ExportingProgressDialog extends StatelessWidget { - const _ExportingProgressDialog(); - @override - Widget build(BuildContext context) => const AlertDialog.adaptive( - icon: Icon(Icons.send_and_archive), - title: Text('Export in progress'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator.adaptive(), - SizedBox(height: 12), - Text( - "Please don't close this dialog or leave the app.\nThe operation " - "will continue if the dialog is closed.\nWe'll let you know once " - "we're done.", - textAlign: TextAlign.center, - ), - ], - ), - ); + if (Platform.isAndroid || Platform.isIOS) { + await Share.shareXFiles([XFile(filePath)]); + await Directory(tempDir).delete(recursive: true); + } + } } diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/name_input_dialog.dart b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/name_input_dialog.dart new file mode 100644 index 00000000..00338305 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/name_input_dialog.dart @@ -0,0 +1,79 @@ +part of 'button.dart'; + +class _ExportingNameInputDialog extends StatefulWidget { + const _ExportingNameInputDialog({ + required this.defaultName, + required this.tempDir, + }); + + final String defaultName; + final String tempDir; + + @override + State<_ExportingNameInputDialog> createState() => + _ExportingNameInputDialogState(); +} + +class _ExportingNameInputDialogState extends State<_ExportingNameInputDialog> { + final inputController = TextEditingController(); + + bool invalidFilename = false; + + @override + Widget build(BuildContext context) => AlertDialog.adaptive( + icon: const Icon(Icons.send_and_archive), + title: const Text('Choose archive name'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: inputController, + decoration: InputDecoration( + hintText: widget.defaultName, + suffixText: '.fmtc', + errorText: invalidFilename ? 'Invalid filename' : null, + ), + onChanged: (_) => setState(() => invalidFilename = false), + onFieldSubmitted: (_) => _validateAndFinish(), + autofocus: true, + ), + const SizedBox(height: 12), + const Text( + "Once we're done, we'll let you share the exported archive " + 'elsewhere.', + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _validateAndFinish, + child: const Text('Export'), + ), + ], + ); + + Future _validateAndFinish() async { + if (inputController.text.isEmpty) { + Navigator.of(context).pop(widget.defaultName); + return; + } + + final file = File( + p.join(widget.tempDir, '${inputController.text}.fmtc.tmp'), + ); + try { + await file.create(); + await file.delete(); + } on FileSystemException { + setState(() => invalidFilename = true); + return; + } + + if (mounted) Navigator.of(context).pop(inputController.text); + } +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/progress_dialog.dart b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/progress_dialog.dart new file mode 100644 index 00000000..40af4f1b --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/progress_dialog.dart @@ -0,0 +1,24 @@ +part of 'button.dart'; + +class _ExportingProgressDialog extends StatelessWidget { + const _ExportingProgressDialog(); + + @override + Widget build(BuildContext context) => const AlertDialog.adaptive( + icon: Icon(Icons.send_and_archive), + title: Text('Export in progress'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator.adaptive(), + SizedBox(height: 12), + Text( + "Please don't close this dialog or leave the app.\nThe operation " + "will continue if the dialog is closed.\nWe'll let you know once " + "we're done.", + textAlign: TextAlign.center, + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart index f49fc3dd..00589cdb 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart @@ -60,26 +60,38 @@ class _StoreTileState extends State { onPressed: _editStore, icon: const Icon(Icons.edit), ), - if (_toolsEmptyLoading) - const Center( - child: SizedBox.square( - dimension: 25, - child: Center( - child: CircularProgressIndicator.adaptive(), - ), - ), - ) - else - IconButton( - onPressed: _emptyStore, - icon: const Icon(Icons.delete), - ), - IconButton( - onPressed: _deleteStore, - icon: const Icon( - Icons.delete_forever, - color: Colors.red, - ), + FutureBuilder( + future: widget.stats, + builder: (context, statsSnapshot) { + if (statsSnapshot.data?.length == 0) { + return IconButton( + onPressed: _deleteStore, + icon: const Icon( + Icons.delete_forever, + color: Colors.red, + ), + ); + } + + if (_toolsEmptyLoading) { + return const IconButton( + onPressed: null, + icon: SizedBox.square( + dimension: 22, + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 3, + ), + ), + ), + ); + } + + return IconButton( + onPressed: _emptyStore, + icon: const Icon(Icons.delete), + ); + }, ), ]; diff --git a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart index 3ca2ca5b..e1c51a35 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import '../../../../../shared/misc/exts/size_formatter.dart'; import 'components/column_headers_and_inheritable_settings.dart'; -import 'components/export_stores_button.dart'; +import 'components/export_stores/button.dart'; import 'components/new_store_button.dart'; import 'components/no_stores.dart'; import 'components/root_tile.dart'; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6a1db9b0..5d6c1bd7 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -29,8 +29,9 @@ dependencies: latlong2: ^0.9.1 osm_nominatim: ^3.0.0 path: ^1.9.0 - path_provider: ^2.1.3 + path_provider: ^2.1.4 provider: ^6.1.2 + share_plus: ^10.0.1 shared_preferences: ^2.2.3 stream_transform: ^2.1.0 validators: ^3.0.0 diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index a84779d7..e84cfb8e 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { ObjectboxFlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 9f0138ed..62043cae 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST objectbox_flutter_libs + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/lib/src/backend/impls/objectbox/backend/backend.dart b/lib/src/backend/impls/objectbox/backend/backend.dart index 20b36722..c1450acc 100644 --- a/lib/src/backend/impls/objectbox/backend/backend.dart +++ b/lib/src/backend/impls/objectbox/backend/backend.dart @@ -29,7 +29,6 @@ part 'internal_workers/standard/cmd_type.dart'; part 'internal_workers/standard/worker.dart'; part 'internal_workers/shared.dart'; part 'internal_workers/thread_safe.dart'; -part 'errors.dart'; part 'internal.dart'; /// {@template fmtc.backend.objectbox} diff --git a/lib/src/backend/impls/objectbox/backend/errors.dart b/lib/src/backend/impls/objectbox/backend/errors.dart deleted file mode 100644 index ec1b6018..00000000 --- a/lib/src/backend/impls/objectbox/backend/errors.dart +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of 'backend.dart'; - -/// An [FMTCBackendError] that originates specifically from the -/// [FMTCObjectBoxBackend] -/// -/// The [FMTCObjectBoxBackend] may also emit errors directly of type -/// [FMTCBackendError]. -base class FMTCObjectBoxBackendError extends FMTCBackendError {} - -/// Indicates that an export failed because the specified output path directory -/// was the same as the root directory -final class ExportInRootDirectoryForbidden extends FMTCObjectBoxBackendError { - /// Indicates that an export failed because the specified output path directory - /// was the same as the root directory - ExportInRootDirectoryForbidden(); - - @override - String toString() => - 'ExportInRootDirectoryForbidden: It is forbidden to export stores to the ' - 'same directory as the `rootDirectory`'; -} diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index bc57c8ad..30c7c0d8 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -818,20 +818,24 @@ Future _worker( final storeNames = cmd.args['storeNames']! as List; final outputPath = cmd.args['outputPath']! as String; - final outputDir = path.dirname(outputPath); + final workingDir = + Directory(path.join(input.rootDirectory, 'export_working_dir')); - if (path.equals(outputDir, input.rootDirectory)) { - throw ExportInRootDirectoryForbidden(); - } - - Directory(outputDir).createSync(recursive: true); + if (workingDir.existsSync()) workingDir.deleteSync(recursive: true); + workingDir.createSync(recursive: true); - final exportingRoot = Store( - getObjectBoxModel(), - directory: outputDir, - maxDBSizeInKB: input.maxDatabaseSize, // Defaults to 10 GB - macosApplicationGroup: input.macosApplicationGroup, - ); + late final Store exportingRoot; + try { + exportingRoot = Store( + getObjectBoxModel(), + directory: workingDir.absolute.path, + maxDBSizeInKB: input.maxDatabaseSize, // Defaults to 10 GB + macosApplicationGroup: input.macosApplicationGroup, + ); + } catch (_) { + workingDir.deleteSync(recursive: true); + rethrow; + } final storesQuery = root .box() @@ -919,11 +923,10 @@ Future _worker( tilesQuery.close(); exportingRoot.close(); - File(path.join(outputDir, 'lock.mdb')).delete(); + final dbFile = + File(path.join(workingDir.absolute.path, 'data.mdb')); - final ram = File(path.join(outputDir, 'data.mdb')) - .renameSync(outputPath) - .openSync(mode: FileMode.writeOnlyAppend); + final ram = dbFile.openSync(mode: FileMode.writeOnlyAppend); try { ram ..writeFromSync(List.filled(4, 255)) @@ -935,33 +938,56 @@ Future _worker( ram.closeSync(); } + try { + dbFile.renameSync(outputPath); + } on FileSystemException { + dbFile.copySync(outputPath); + } finally { + workingDir.deleteSync(recursive: true); + } + sendRes(id: cmd.id); }, - ); + ).catchError((error, stackTrace) { + exportingRoot.close(); + try { + workingDir.deleteSync(recursive: true); + // ignore: empty_catches + } on FileSystemException {} + Error.throwWithStackTrace(error, stackTrace); + }); }, - ); + ).catchError((error, stackTrace) { + exportingRoot.close(); + try { + workingDir.deleteSync(recursive: true); + // ignore: empty_catches + } on FileSystemException {} + Error.throwWithStackTrace(error, stackTrace); + }); case _CmdType.importStores: final importPath = cmd.args['path']! as String; final strategy = cmd.args['strategy'] as ImportConflictStrategy; final storesToImport = cmd.args['stores'] as List?; - final importDir = path.join(input.rootDirectory, 'import_tmp'); - final importDirIO = Directory(importDir)..createSync(); + final workingDir = + Directory(path.join(input.rootDirectory, 'import_working_dir')); + if (workingDir.existsSync()) workingDir.deleteSync(recursive: true); + workingDir.createSync(recursive: true); - final importFile = - File(importPath).copySync(path.join(importDir, 'data.mdb')); + final importFile = File(importPath) + .copySync(path.join(workingDir.absolute.path, 'data.mdb')); try { verifyImportableArchive(importFile); } catch (e) { - importFile.deleteSync(); - importDirIO.deleteSync(); + workingDir.deleteSync(recursive: true); rethrow; } final importingRoot = Store( getObjectBoxModel(), - directory: importDir, + directory: workingDir.absolute.path, maxDBSizeInKB: input.maxDatabaseSize, macosApplicationGroup: input.macosApplicationGroup, ); @@ -983,10 +1009,7 @@ Future _worker( importingStoresQuery.close(); specificStoresQuery.close(); importingRoot.close(); - - importFile.deleteSync(); - File(path.join(importDir, 'lock.mdb')).deleteSync(); - importDirIO.deleteSync(); + workingDir.deleteSync(recursive: true); } final StoresToStates storesToStates = {}; diff --git a/lib/src/root/external.dart b/lib/src/root/external.dart index 3765a7c8..a5de5bbc 100644 --- a/lib/src/root/external.dart +++ b/lib/src/root/external.dart @@ -32,28 +32,41 @@ typedef StoresToStates = Map; /// the backend, but FMTC specific information has been attached, and therefore /// the file will be unreadable by non-FMTC database implementations. /// -/// If the specified archive (at [pathToArchive]) is not of the expected format, -/// an error from the [ImportExportError] group will be thrown: -/// -/// - Doesn't exist (except [export]): [ImportPathNotExists] -/// - Not a file: [ImportExportPathNotFile] -/// - Not an FMTC archive: [ImportFileNotFMTCStandard] -/// - Not compatible with the current backend: [ImportFileNotBackendCompatible] -/// /// Importing (especially) and exporting operations are likely to be slow. It is /// not recommended to attempt to use other FMTC operations during the /// operation, to avoid slowing it further or potentially causing inconsistent /// state. +/// +/// Importing and exporting operations may consume more storage capacity than +/// expected, especially temporarily during the operation. class RootExternal { const RootExternal._(this.pathToArchive); - /// The path to an archive file + /// The path to an archive file (which may or may not exist) + /// + /// It should only point to a file. When used with [export], the file does not + /// have to exist. Otherwise, it should exist. + /// + /// > [!IMPORTANT] + /// > The path must be accessible to the application. For example, on Android + /// > devices, it should not be in external storage, unless the app has the + /// > appropriate (dangerous) permissions. + /// > + /// > On mobile platforms (/those platforms which operate sandboxed storage), it + /// > is recommended to set this path to a path the application can definitely + /// > control (such as app support), using a path from 'package:path_provider', + /// > then share it somewhere else using the system flow (using + /// > 'package:share_plus'). final String pathToArchive; /// Creates an archive at [pathToArchive] containing the specified stores and /// their tiles /// - /// If a file already exists at [pathToArchive], it will be overwritten. + /// If [pathToArchive] already exists as a file, it will be overwritten. It + /// must not already exist as anything other than a file. The path must be + /// accessible to the application: see [pathToArchive] for information. + /// + /// The specified stores must contain at least one tile. Future export({ required List storeNames, }) => @@ -61,6 +74,17 @@ class RootExternal { .exportStores(storeNames: storeNames, path: pathToArchive); /// Imports specified stores and all necessary tiles into the current root + /// from [pathToArchive] + /// + /// {@template fmtc.external.import.pathToArchiveRequirements} + /// [pathToArchive] must exist as an compatible file. The path must be + /// accessible to the application: see [pathToArchive] for information. If it + /// does not exist, [ImportPathNotExists] will be thrown. If it exists, but is + /// not a file, [ImportExportPathNotFile] will be thrown. If it exists, but is + /// not an FMTC archive, [ImportFileNotFMTCStandard] will be thrown. If it is + /// an FMTC archive, but not compatible with the current backend, + /// [ImportFileNotBackendCompatible] will be thrown. + /// {@endtemplate} /// /// See [ImportConflictStrategy] to set how conflicts between existing and /// importing stores should be resolved. Defaults to @@ -76,6 +100,8 @@ class RootExternal { ); /// List the available store names within the archive at [pathToArchive] + /// + /// {@macro fmtc.external.import.pathToArchiveRequirements} Future> get listStores => FMTCBackendAccess.internal.listImportableStores(path: pathToArchive); } From 169f82e05682333ba0323fe184c63b621562f63f Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 13 Aug 2024 00:52:56 +0100 Subject: [PATCH 35/97] Implemented import flow in example application Minor documentation improvements --- example/lib/main.dart | 9 + .../components/export_stores/button.dart | 2 +- .../stores/components/new_store_button.dart | 3 +- .../panels/stores/components/no_stores.dart | 3 +- .../panels/stores/components/store_tile.dart | 2 - .../src/screens/home/map_view/map_view.dart | 3 +- example/lib/src/screens/import/import.dart | 148 +++++++++++++ .../src/screens/import/stages/complete.dart | 45 ++++ .../lib/src/screens/import/stages/error.dart | 49 +++++ .../src/screens/import/stages/loading.dart | 70 +++++++ .../src/screens/import/stages/progress.dart | 77 +++++++ .../src/screens/import/stages/selection.dart | 129 ++++++++++++ .../initialisation_error.dart | 25 ++- .../screens/store_editor/store_editor.dart | 195 ------------------ .../shared/components/loading_indicator.dart | 4 +- lib/src/root/external.dart | 33 ++- 16 files changed, 571 insertions(+), 226 deletions(-) create mode 100644 example/lib/src/screens/import/import.dart create mode 100644 example/lib/src/screens/import/stages/complete.dart create mode 100644 example/lib/src/screens/import/stages/error.dart create mode 100644 example/lib/src/screens/import/stages/loading.dart create mode 100644 example/lib/src/screens/import/stages/progress.dart create mode 100644 example/lib/src/screens/import/stages/selection.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 00b102a3..ae553afc 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'src/screens/home/config_view/panels/stores/state/export_selection_provider.dart'; import 'src/screens/home/home.dart'; import 'src/screens/home/map_view/state/region_selection_provider.dart'; +import 'src/screens/import/import.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; @@ -51,6 +52,14 @@ class _AppContainer extends StatelessWidget { fullscreenDialog: true, ), ), + ImportPopup.route: ( + std: null, + custom: (context, settings) => MaterialPageRoute( + builder: (context) => const ImportPopup(), + settings: settings, + fullscreenDialog: true, + ), + ), /* ProfileScreen.route: ( std: (BuildContext context) => const ProfileScreen(), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart index 3c9fa57a..d715128b 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart @@ -85,7 +85,7 @@ class ExportStoresButton extends StatelessWidget { final fileNameTime = DateTime.now().toString().split('.').first.replaceAll(':', '-'); - late final String filePath; + final String filePath; late final String tempDir; if (Platform.isAndroid || Platform.isIOS) { tempDir = p.join( diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart b/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart index 0bbd8975..a88a4193 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../../../../import/import.dart'; import '../../../../../store_editor/store_editor.dart'; class NewStoreButton extends StatelessWidget { @@ -28,7 +29,7 @@ class NewStoreButton extends StatelessWidget { IconButton.outlined( icon: const Icon(Icons.file_open), tooltip: 'Import store', - onPressed: () {}, + onPressed: () => ImportPopup.start(context), ), ], ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart b/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart index 55db7559..e42ce38f 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../../../../import/import.dart'; import '../../../../../store_editor/store_editor.dart'; class NoStores extends StatelessWidget { @@ -44,7 +45,7 @@ class NoStores extends StatelessWidget { height: 42, width: double.infinity, child: OutlinedButton.icon( - onPressed: () {}, + onPressed: () => ImportPopup.start(context), icon: const Icon(Icons.file_open), label: const Text('Import a store'), ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart index 00589cdb..de75791f 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart @@ -298,10 +298,8 @@ class _StoreTileState extends State { } Future _emptyStore() async { - _toolsAutoHiderTimer?.cancel(); setState(() => _toolsEmptyLoading = true); await widget.store.manage.reset(); - await _hideTools(); setState(() => _toolsEmptyLoading = false); } diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart index 1a05cbcf..e74e6541 100644 --- a/example/lib/src/screens/home/map_view/map_view.dart +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -219,7 +219,8 @@ class _MapViewState extends State with TickerProviderStateMixin { stream: _storesStream, builder: (context, snapshot) { if (snapshot.data == null) { - return const AbsorbPointer(child: LoadingIndicator('Preparing map')); + return const AbsorbPointer( + child: SharedLoadingIndicator('Preparing map')); } final stores = snapshot.data!; diff --git a/example/lib/src/screens/import/import.dart b/example/lib/src/screens/import/import.dart new file mode 100644 index 00000000..0df6fed4 --- /dev/null +++ b/example/lib/src/screens/import/import.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +import 'stages/complete.dart'; +import 'stages/error.dart'; +import 'stages/loading.dart'; +import 'stages/progress.dart'; +import 'stages/selection.dart'; + +class ImportPopup extends StatefulWidget { + const ImportPopup({super.key}); + + static const String route = '/import'; + + static Future start(BuildContext context) async { + final pickerResult = Platform.isAndroid || Platform.isIOS + ? FilePicker.platform.pickFiles() + : FilePicker.platform.pickFiles( + dialogTitle: 'Import Archive', + type: FileType.custom, + allowedExtensions: ['fmtc'], + ); + final filePath = (await pickerResult)?.paths.single; + + if (filePath == null || !context.mounted) return; + + await Navigator.of(context).pushNamed( + ImportPopup.route, + arguments: filePath, + ); + } + + @override + State createState() => _ImportPopupState(); +} + +class _ImportPopupState extends State { + RootExternal? fmtcExternal; + + int stage = 1; + + late Object error; // Stage 0 + + late Map availableStores; // Stage 1 -> 2 + + late Set selectedStores; // Stage 2 -> 3 + late ImportConflictStrategy conflictStrategy; // Stage 2 -> 3 + + late int importTilesResult; // Stage 3 -> 4 + late Duration importDuration; // Stage 3 -> 4 + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + fmtcExternal ??= FMTCRoot.external( + pathToArchive: ModalRoute.of(context)!.settings.arguments! as String, + ); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: stage != 3, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "We don't recommend leaving this screen while the import is " + 'in progress', + ), + ), + ); + } + }, + child: Scaffold( + appBar: AppBar( + title: const Text('Import Archive'), + automaticallyImplyLeading: stage != 3, + elevation: 1, + ), + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) => SlideTransition( + position: (animation.value == 1 + ? Tween(begin: const Offset(-1, 0), end: Offset.zero) + : Tween(begin: const Offset(1, 0), end: Offset.zero)) + .animate(animation), + child: child, + ), + child: switch (stage) { + 0 => ImportErrorStage(error: error), + 1 => ImportLoadingStage( + fmtcExternal: fmtcExternal!, + nextStage: Completer() + ..future.then( + (availableStores) => setState(() { + this.availableStores = availableStores; + stage++; + }), + onError: (err) => setState(() { + error = err; + stage = 0; + }), + ), + ), + 2 => ImportSelectionStage( + fmtcExternal: fmtcExternal!, + availableStores: availableStores, + nextStage: (selectedStores, conflictStrategy) => setState(() { + this.selectedStores = selectedStores; + this.conflictStrategy = conflictStrategy; + stage++; + }), + ), + 3 => ImportProgressStage( + fmtcExternal: fmtcExternal!, + selectedStores: selectedStores, + conflictStrategy: conflictStrategy, + nextStage: Completer() + ..future.then( + (result) => setState(() { + importTilesResult = result.tiles; + importDuration = result.duration; + stage++; + }), + onError: (err) => setState(() { + error = err; + stage = 0; + }), + ), + ), + 4 => ImportCompleteStage( + tiles: importTilesResult, + duration: importDuration, + ), + _ => throw UnimplementedError(), + }, + ), + ), + ); +} diff --git a/example/lib/src/screens/import/stages/complete.dart b/example/lib/src/screens/import/stages/complete.dart new file mode 100644 index 00000000..3b67a1d5 --- /dev/null +++ b/example/lib/src/screens/import/stages/complete.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class ImportCompleteStage extends StatelessWidget { + const ImportCompleteStage({ + super.key, + required this.tiles, + required this.duration, + }); + + final int tiles; + final Duration duration; + + @override + Widget build(BuildContext context) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99), + color: Colors.green, + ), + child: const Padding( + padding: EdgeInsets.all(12), + child: Icon(Icons.done_all, size: 48, color: Colors.white), + ), + ), + const SizedBox(height: 16), + Text( + 'Successfully imported $tiles tiles in $duration!', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + 'Exit', + textAlign: TextAlign.center, + ), + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/import/stages/error.dart b/example/lib/src/screens/import/stages/error.dart new file mode 100644 index 00000000..8cc46b3e --- /dev/null +++ b/example/lib/src/screens/import/stages/error.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class ImportErrorStage extends StatelessWidget { + const ImportErrorStage({super.key, required this.error}); + + final Object error; + + @override + Widget build(BuildContext context) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.broken_image, size: 48), + const SizedBox(height: 6), + Text( + "Whoops, looks like we couldn't handle that file", + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + const Text( + "Ensure you selected the correct file, that it hasn't " + 'been modified, and that it was exported from the same ' + 'version of FMTC.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + SelectableText( + 'Type: ${error.runtimeType}', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + SelectableText( + 'Error: $error', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + 'Cancel', + textAlign: TextAlign.center, + ), + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/import/stages/loading.dart b/example/lib/src/screens/import/stages/loading.dart new file mode 100644 index 00000000..cca29245 --- /dev/null +++ b/example/lib/src/screens/import/stages/loading.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +class ImportLoadingStage extends StatefulWidget { + const ImportLoadingStage({ + super.key, + required this.fmtcExternal, + required this.nextStage, + }); + + final RootExternal fmtcExternal; + final Completer> nextStage; + + @override + State createState() => _ImportLoadingStageState(); +} + +class _ImportLoadingStageState extends State { + @override + void initState() { + super.initState(); + + widget.fmtcExternal.listStores + .then( + (stores) async => Map.fromEntries( + await Future.wait( + stores + .map( + (storeName) async => MapEntry( + storeName, + await FMTCStore(storeName).manage.ready, + ), + ) + .toList(), + ), + ), + ) + .then( + widget.nextStage.complete, + onError: widget.nextStage.completeError, + ); + } + + @override + Widget build(BuildContext context) => const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + SizedBox.square( + dimension: 64, + child: CircularProgressIndicator.adaptive(), + ), + Icon(Icons.file_open, size: 32), + ], + ), + SizedBox(height: 16), + Text( + "We're just preparing the archive for you...\nThis could " + 'take a few moments.', + textAlign: TextAlign.center, + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/import/stages/progress.dart b/example/lib/src/screens/import/stages/progress.dart new file mode 100644 index 00000000..74fc24fc --- /dev/null +++ b/example/lib/src/screens/import/stages/progress.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +class ImportProgressStage extends StatefulWidget { + const ImportProgressStage({ + super.key, + required this.fmtcExternal, + required this.selectedStores, + required this.conflictStrategy, + required this.nextStage, + }); + + final RootExternal fmtcExternal; + final Set selectedStores; + final ImportConflictStrategy conflictStrategy; + final Completer<({int tiles, Duration duration})> nextStage; + + @override + State createState() => _ImportProgressStageState(); +} + +class _ImportProgressStageState extends State { + @override + void initState() { + super.initState(); + + final start = DateTime.timestamp(); + widget.fmtcExternal + .import( + storeNames: widget.selectedStores.toList(), + strategy: widget.conflictStrategy, + ) + .complete + .then( + (tiles) => widget.nextStage.complete( + (tiles: tiles, duration: DateTime.timestamp().difference(start)), + ), + onError: widget.nextStage.completeError, + ); + } + + @override + Widget build(BuildContext context) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Stack( + alignment: Alignment.center, + children: [ + SizedBox.square( + dimension: 64, + child: CircularProgressIndicator.adaptive(), + ), + Icon(Icons.file_open, size: 32), + ], + ), + const SizedBox(height: 16), + Text( + "We're importing your stores...", + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + const Text( + 'This could take a while.\n' + "We don't recommend leaving this screen. The import will " + 'continue, but performance could be affected.\n' + 'Closing the app will stop the import operation in an ' + 'indeterminate (but stable) state.', + textAlign: TextAlign.center, + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/import/stages/selection.dart b/example/lib/src/screens/import/stages/selection.dart new file mode 100644 index 00000000..909a0f9c --- /dev/null +++ b/example/lib/src/screens/import/stages/selection.dart @@ -0,0 +1,129 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +class ImportSelectionStage extends StatefulWidget { + const ImportSelectionStage({ + super.key, + required this.fmtcExternal, + required this.availableStores, + required this.nextStage, + }); + + final RootExternal fmtcExternal; + final Map availableStores; + final void Function( + Set selectedStores, + ImportConflictStrategy conflictStrategy, + ) nextStage; + + @override + State createState() => _ImportSelectionStageState(); +} + +class _ImportSelectionStageState extends State { + late final Set selectedStores = widget.availableStores.keys.toSet(); + ImportConflictStrategy conflictStrategy = ImportConflictStrategy.rename; + + @override + Widget build(BuildContext context) => Column( + children: [ + Expanded( + child: Scrollbar( + child: ListView.builder( + itemCount: widget.availableStores.length, + itemBuilder: (context, index) { + final storeName = + widget.availableStores.keys.elementAt(index); + final collision = + widget.availableStores.values.elementAt(index); + + return CheckboxListTile.adaptive( + title: Text(storeName), + subtitle: Text(collision ? 'Collision' : 'No collision'), + value: !(collision && + conflictStrategy == ImportConflictStrategy.skip) && + selectedStores.contains(storeName), + onChanged: collision && + conflictStrategy == ImportConflictStrategy.skip + ? null + : (v) => setState( + () => (v! + ? selectedStores.add + : selectedStores.remove) + .call(storeName), + ), + ); + }, + ), + ), + ), + ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: DropdownButton( + isExpanded: true, + value: conflictStrategy, + items: ImportConflictStrategy.values.map( + (e) { + final icon = switch (e) { + ImportConflictStrategy.merge => Icons.merge_rounded, + ImportConflictStrategy.rename => Icons.edit_rounded, + ImportConflictStrategy.replace => + Icons.save_as_rounded, + ImportConflictStrategy.skip => + Icons.skip_next_rounded, + }; + final text = switch (e) { + ImportConflictStrategy.merge => + 'Merge existing & conflicting stores', + ImportConflictStrategy.rename => + 'Rename conflicting stores (append date & time)', + ImportConflictStrategy.replace => + 'Replace existing stores', + ImportConflictStrategy.skip => + 'Skip conflicting stores', + }; + + return DropdownMenuItem( + value: e, + child: Row( + children: [ + Icon(icon), + const SizedBox(width: 12), + Text(text), + ], + ), + ); + }, + ).toList(growable: false), + onChanged: (c) => setState(() => conflictStrategy = c!), + ), + ), + const SizedBox(width: 16), + IconButton.filled( + onPressed: selectedStores.isNotEmpty && + (conflictStrategy != ImportConflictStrategy.skip || + selectedStores + .whereNot( + (store) => widget.availableStores[store]!, + ) + .isNotEmpty) + ? () => widget.nextStage( + selectedStores, + conflictStrategy, + ) + : null, + icon: const Icon(Icons.file_open), + ), + ], + ), + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/initialisation_error/initialisation_error.dart b/example/lib/src/screens/initialisation_error/initialisation_error.dart index a732e5eb..cd8c6238 100644 --- a/example/lib/src/screens/initialisation_error/initialisation_error.dart +++ b/example/lib/src/screens/initialisation_error/initialisation_error.dart @@ -19,30 +19,29 @@ class InitialisationError extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error, size: 64), - const SizedBox(height: 12), + const Icon(Icons.error, size: 48), + const SizedBox(height: 6), Text( 'Whoops, look like FMTC ran into an error initialising', - style: Theme.of(context).textTheme.displaySmall, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + const Text( + 'We recommend trying to delete the existing root, as it may ' + 'have become corrupt.\nPlease be aware that this will delete ' + 'any cached data, and will cause the app to restart.', textAlign: TextAlign.center, ), const SizedBox(height: 16), SelectableText( 'Type: ${err.runtimeType}', - style: Theme.of(context).textTheme.headlineSmall, + style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ), SelectableText( 'Error: $err', - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - 'We recommend trying to delete the existing root, as it may ' - 'have become corrupt.\nPlease be aware that this will delete ' - 'any cached data, and will cause the app to restart.', - style: Theme.of(context).textTheme.bodyLarge, + style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), const SizedBox(height: 32), diff --git a/example/lib/src/screens/store_editor/store_editor.dart b/example/lib/src/screens/store_editor/store_editor.dart index f9fb7e46..cbaa3dd8 100644 --- a/example/lib/src/screens/store_editor/store_editor.dart +++ b/example/lib/src/screens/store_editor/store_editor.dart @@ -192,200 +192,5 @@ class _StoreEditorPopupState extends State { ? const Icon(Icons.save) : const Icon(Icons.save_as), ), - /*body: Consumer( - builder: (context, provider, _) => Padding( - padding: const EdgeInsets.all(12), - child: FutureBuilder?>( - future:existingStoreName == null - ? Future.sync(() => {}) - : FMTCStore(existingStoreName!).metadata.read, - builder: (context, metadata) { - if (!metadata.hasData || metadata.data == null) { - return const LoadingIndicator('Retrieving Settings'); - } - return Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - children: [ - TextFormField( - decoration: const InputDecoration( - labelText: 'Store Name', - prefixIcon: Icon(Icons.text_fields), - isDense: true, - ), - onChanged: (input) async { - _storeNameIsDuplicate = - (await FMTCRoot.stats.storesAvailable) - .contains(FMTCStore(input)); - setState(() {}); - }, - validator: (input) => input == null || input.isEmpty - ? 'Required' - : _storeNameIsDuplicate - ? 'Store already exists' - : null, - onSaved: (input) => _newValues['storeName'] = input!, - autovalidateMode: AutovalidateMode.onUserInteraction, - textCapitalization: TextCapitalization.words, - initialValue: widget.existingStoreName, - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 5), - TextFormField( - decoration: const InputDecoration( - labelText: 'Map Source URL', - helperText: - "Use '{x}', '{y}', '{z}' as placeholders. Include protocol. Omit subdomain.", - prefixIcon: Icon(Icons.link), - isDense: true, - ), - onChanged: (i) async { - final uri = Uri.tryParse( - NetworkTileProvider().getTileUrl( - const TileCoordinates(0, 0, 0), - TileLayer(urlTemplate: i), - ), - ); - - if (uri == null) { - setState( - () => _httpRequestFailed = 'Invalid URL', - ); - return; - } - - _httpRequestFailed = await http.get(uri).then( - (res) => res.statusCode == 200 - ? null - : 'HTTP Request Failed', - onError: (_) => 'HTTP Request Failed', - ); - setState(() {}); - }, - validator: (i) { - final String input = i ?? ''; - - if (!validators.isURL( - input, - protocols: ['http', 'https'], - requireProtocol: true, - )) { - return 'Invalid URL'; - } - if (!input.contains('{x}') || - !input.contains('{y}') || - !input.contains('{z}')) { - return 'Missing placeholder(s)'; - } - - return _httpRequestFailed; - }, - onSaved: (input) => _newValues['sourceURL'] = input!, - autovalidateMode: AutovalidateMode.onUserInteraction, - keyboardType: TextInputType.url, - initialValue: metadata.data!.isEmpty - ? 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' - : metadata.data!['sourceURL'], - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 5), - TextFormField( - decoration: const InputDecoration( - labelText: 'Valid Cache Duration', - helperText: 'Use 0 to disable expiry', - suffixText: 'days', - prefixIcon: Icon(Icons.timelapse), - isDense: true, - ), - validator: (input) { - if (input == null || - input.isEmpty || - int.parse(input) < 0) { - return 'Must be 0 or more'; - } - return null; - }, - onSaved: (input) => - _newValues['validDuration'] = input!, - autovalidateMode: AutovalidateMode.onUserInteraction, - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - initialValue: metadata.data!.isEmpty - ? '14' - : metadata.data!['validDuration'], - textInputAction: TextInputAction.done, - ), - const SizedBox(height: 5), - TextFormField( - decoration: const InputDecoration( - labelText: 'Maximum Length', - helperText: 'Use 0 to disable limit', - suffixText: 'tiles', - prefixIcon: Icon(Icons.disc_full), - isDense: true, - ), - validator: (input) { - if (input == null || - input.isEmpty || - int.parse(input) < 0) { - return 'Must be 0 or more'; - } - return null; - }, - onSaved: (input) => _newValues['maxLength'] = input!, - autovalidateMode: AutovalidateMode.onUserInteraction, - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - initialValue: metadata.data!.isEmpty - ? '100000' - : metadata.data!['maxLength'], - textInputAction: TextInputAction.done, - ), - Row( - children: [ - const Text('Cache Behaviour:'), - const SizedBox(width: 10), - Expanded( - child: DropdownButton( - value: _useNewCacheModeValue - ? _cacheModeValue! - : metadata.data!.isEmpty - ? 'cacheFirst' - : metadata.data!['behaviour'], - onChanged: (newVal) => setState( - () { - _cacheModeValue = newVal ?? 'cacheFirst'; - _useNewCacheModeValue = true; - }, - ), - items: [ - 'cacheFirst', - 'onlineFirst', - 'cacheOnly', - ] - .map>( - (v) => DropdownMenuItem( - value: v, - child: Text(v), - ), - ) - .toList(), - ), - ), - ], - ), - ], - ), - ), - ); - }, - ), - ), - ),*/ ); } diff --git a/example/lib/src/shared/components/loading_indicator.dart b/example/lib/src/shared/components/loading_indicator.dart index 99a47058..b3675407 100644 --- a/example/lib/src/shared/components/loading_indicator.dart +++ b/example/lib/src/shared/components/loading_indicator.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -class LoadingIndicator extends StatelessWidget { - const LoadingIndicator(this.text, {super.key}); +class SharedLoadingIndicator extends StatelessWidget { + const SharedLoadingIndicator(this.text, {super.key}); final String text; diff --git a/lib/src/root/external.dart b/lib/src/root/external.dart index a5de5bbc..5359c3f0 100644 --- a/lib/src/root/external.dart +++ b/lib/src/root/external.dart @@ -26,11 +26,24 @@ typedef StoresToStates = Map; /// Export & import 'archives' of selected stores and tiles, outside of the /// FMTC environment /// -/// Archives are backend specific, and FMTC specific. They cannot necessarily -/// be imported by a backend different to the one that exported it. The -/// archive may hold a similar form to the raw format of the database used by -/// the backend, but FMTC specific information has been attached, and therefore -/// the file will be unreadable by non-FMTC database implementations. +/// --- +/// +/// Archives are backend specific. They cannot be imported by a backend +/// different to the one that exported it. +/// +/// Archives are only readable by FMTC. The archive may hold a similar form to +/// the raw format of the database used by the backend, but FMTC-specific +/// information has been attached, and therefore the file will be unreadable by +/// non-FMTC database implementations. +/// +/// Archives are potentially backend/FMTC version specific, dependent on whether +/// the database schema was changed. An archive created on an older schema is +/// usually importable into a newer schema, but this is not guaranteed. An +/// archive created in a newer schema cannot be imported into an older schema. +/// Note that this is not enforced by the archive format, and the schema may not +/// change between FMTC or backend version changes. +/// +/// --- /// /// Importing (especially) and exporting operations are likely to be slow. It is /// not recommended to attempt to use other FMTC operations during the @@ -52,11 +65,11 @@ class RootExternal { /// > devices, it should not be in external storage, unless the app has the /// > appropriate (dangerous) permissions. /// > - /// > On mobile platforms (/those platforms which operate sandboxed storage), it - /// > is recommended to set this path to a path the application can definitely - /// > control (such as app support), using a path from 'package:path_provider', - /// > then share it somewhere else using the system flow (using - /// > 'package:share_plus'). + /// > On mobile platforms (/those platforms which operate sandboxed storage), + /// > if the app does not have external storage permissions, it is recommended + /// > to set this path to a path the application can definitely control (such + /// > as app support), using a path from 'package:path_provider', then share + /// > it somewhere else using the system flow (using 'package:share_plus'). final String pathToArchive; /// Creates an archive at [pathToArchive] containing the specified stores and From e4d6b55b2f540e1a74d3189ad6ff6e311dc51b75 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 13 Aug 2024 15:01:40 +0100 Subject: [PATCH 36/97] Added return of number of exported tiles to `RootExternal.export` --- CHANGELOG.md | 1 + .../components/export_stores/button.dart | 5 ++-- .../src/screens/home/map_view/map_view.dart | 18 ++++++++++++-- .../shared/components/loading_indicator.dart | 24 ------------------- .../impls/objectbox/backend/internal.dart | 6 ++--- .../internal_workers/standard/worker.dart | 5 +++- .../backend/interfaces/backend/internal.dart | 4 +++- lib/src/root/external.dart | 4 +++- 8 files changed, 33 insertions(+), 34 deletions(-) delete mode 100644 example/lib/src/shared/components/loading_indicator.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index de692f91..f2daab49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Additionally, vector tiles are now supported in theory, as the internal caching/ * Replaced `FMTCBrowsingErrorHandler` with `BrowsingExceptionHandler`, which may now return bytes to be displayed instead of (re)throwing exception * Removed `FMTCTileProviderSettings` & absorbed properties directly into `FMTCTileProvider` * Improved speed (by massive amounts) and accuracy & reduced memory consumption of `CircleRegion`'s tile generation & counting algorithm +* `RootExternal.export` now returns the number of exported tiles * Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `FMTCTileProvider.urlTransformerOmitKeyValues` utility method to provide old behaviour with more customizability) * Removed deprecated remnants from v9.* * Other generic improvements diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart index d715128b..01c31213 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart @@ -162,7 +162,7 @@ class ExportStoresButton extends StatelessWidget { ); final startTime = DateTime.timestamp(); - await FMTCRoot.external(pathToArchive: filePath) + final tiles = await FMTCRoot.external(pathToArchive: filePath) .export(storeNames: provider.selectedStores); provider.clearSelectedStores(); @@ -172,7 +172,8 @@ class ExportStoresButton extends StatelessWidget { ScaffoldMessenger.maybeOf(context)?.showSnackBar( SnackBar( content: Text( - 'Export complete (in ${DateTime.timestamp().difference(startTime)})', + 'Exported $tiles tiles in ' + '${DateTime.timestamp().difference(startTime)}', ), ), ); diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart index e74e6541..1f93a9b2 100644 --- a/example/lib/src/screens/home/map_view/map_view.dart +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -8,7 +8,6 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import '../../../shared/components/loading_indicator.dart'; import '../../../shared/misc/shared_preferences.dart'; import '../../../shared/misc/store_metadata_keys.dart'; import '../../../shared/state/general_provider.dart'; @@ -220,7 +219,22 @@ class _MapViewState extends State with TickerProviderStateMixin { builder: (context, snapshot) { if (snapshot.data == null) { return const AbsorbPointer( - child: SharedLoadingIndicator('Preparing map')); + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator.adaptive(), + SizedBox(height: 12), + Text('Preparing map...', textAlign: TextAlign.center), + Text( + 'This should only take a few moments', + textAlign: TextAlign.center, + style: TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + ), + ); } final stores = snapshot.data!; diff --git a/example/lib/src/shared/components/loading_indicator.dart b/example/lib/src/shared/components/loading_indicator.dart deleted file mode 100644 index b3675407..00000000 --- a/example/lib/src/shared/components/loading_indicator.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; - -class SharedLoadingIndicator extends StatelessWidget { - const SharedLoadingIndicator(this.text, {super.key}); - - final String text; - - @override - Widget build(BuildContext context) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator.adaptive(), - const SizedBox(height: 12), - Text(text, textAlign: TextAlign.center), - const Text( - 'This should only take a few moments', - textAlign: TextAlign.center, - style: TextStyle(fontStyle: FontStyle.italic), - ), - ], - ), - ); -} diff --git a/lib/src/backend/impls/objectbox/backend/internal.dart b/lib/src/backend/impls/objectbox/backend/internal.dart index 9a4bf80b..71cd429b 100644 --- a/lib/src/backend/impls/objectbox/backend/internal.dart +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -595,7 +595,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ); @override - Future exportStores({ + Future exportStores({ required List storeNames, required String path, }) async { @@ -608,10 +608,10 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { throw ImportExportPathNotFile(); } - await _sendCmdOneShot( + return (await _sendCmdOneShot( type: _CmdType.exportStores, args: {'storeNames': storeNames, 'outputPath': path}, - ); + ))!['numExportedTiles']; } @override diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index 30c7c0d8..b2b137f9 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -946,7 +946,10 @@ Future _worker( workingDir.deleteSync(recursive: true); } - sendRes(id: cmd.id); + sendRes( + id: cmd.id, + data: {'numExportedTiles': numExportedTiles}, + ); }, ).catchError((error, stackTrace) { exportingRoot.close(); diff --git a/lib/src/backend/interfaces/backend/internal.dart b/lib/src/backend/interfaces/backend/internal.dart index 50aa4e58..d626cca1 100644 --- a/lib/src/backend/interfaces/backend/internal.dart +++ b/lib/src/backend/interfaces/backend/internal.dart @@ -365,7 +365,9 @@ abstract interface class FMTCBackendInternal /// /// See [RootExternal] for more information about expected behaviour and /// errors. - Future exportStores({ + /// + /// Returns the number of exported tiles. + Future exportStores({ required String path, required List storeNames, }); diff --git a/lib/src/root/external.dart b/lib/src/root/external.dart index 5359c3f0..bafb941e 100644 --- a/lib/src/root/external.dart +++ b/lib/src/root/external.dart @@ -80,7 +80,9 @@ class RootExternal { /// accessible to the application: see [pathToArchive] for information. /// /// The specified stores must contain at least one tile. - Future export({ + /// + /// Returns the number of exported tiles. + Future export({ required List storeNames, }) => FMTCBackendAccess.internal From 07250951cf7a75b5660a39988dc8ef8c2c145612 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 13 Aug 2024 17:19:38 +0100 Subject: [PATCH 37/97] Added bulk download support to example app --- example/lib/main.dart | 48 ++- .../components/region_information.dart | 242 ++++++------ .../components/start_download_button.dart | 179 ++++----- .../components/store_selector.dart | 45 +-- .../configure_download.dart | 237 ++++++------ .../state/configure_download_provider.dart | 15 +- .../confirm_cancellation_dialog.dart | 44 +++ .../download/components/main_statistics.dart | 134 +++++++ .../multi_linear_progress_indicator.dart | 88 +++++ .../download/components/stat_display.dart | 33 ++ .../download/components/stats_table.dart | 86 +++++ .../lib/src/screens/download/download.dart | 343 ++++++++++++++++++ .../region_selection/side_panel/parent.dart | 6 +- .../state/region_selection_provider.dart | 7 - 14 files changed, 1108 insertions(+), 399 deletions(-) create mode 100644 example/lib/src/screens/download/components/confirm_cancellation_dialog.dart create mode 100644 example/lib/src/screens/download/components/main_statistics.dart create mode 100644 example/lib/src/screens/download/components/multi_linear_progress_indicator.dart create mode 100644 example/lib/src/screens/download/components/stat_display.dart create mode 100644 example/lib/src/screens/download/components/stats_table.dart create mode 100644 example/lib/src/screens/download/download.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index ae553afc..8f3839c4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -4,6 +4,9 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'src/screens/configure_download/configure_download.dart'; +import 'src/screens/configure_download/state/configure_download_provider.dart'; +import 'src/screens/download/download.dart'; import 'src/screens/home/config_view/panels/stores/state/export_selection_provider.dart'; import 'src/screens/home/home.dart'; import 'src/screens/home/map_view/state/region_selection_provider.dart'; @@ -60,35 +63,22 @@ class _AppContainer extends StatelessWidget { fullscreenDialog: true, ), ), - /* - ProfileScreen.route: ( - std: (BuildContext context) => const ProfileScreen(), - custom: ({ - required Widget Function( - BuildContext, - Animation, - Animation, - ) pageBuilder, - required RouteSettings settings, - }) => - PageRouteBuilder( - pageBuilder: pageBuilder, + ConfigureDownloadPopup.route: ( + std: null, + custom: (context, settings) => MaterialPageRoute( + builder: (context) => const ConfigureDownloadPopup(), settings: settings, - transitionsBuilder: (context, animation, _, child) { - const begin = Offset(0, 1); - const end = Offset.zero; - const curve = Curves.ease; - - final tween = - Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); - - return SlideTransition( - position: animation.drive(tween), - child: child, - ); - }, + fullscreenDialog: true, ), - ),*/ + ), + DownloadPopup.route: ( + std: null, + custom: (context, settings) => MaterialPageRoute( + builder: (context) => const DownloadPopup(), + settings: settings, + fullscreenDialog: true, + ), + ), }; @override @@ -127,6 +117,10 @@ class _AppContainer extends StatelessWidget { create: (_) => RegionSelectionProvider(), lazy: true, ), + ChangeNotifierProvider( + create: (_) => ConfigureDownloadProvider(), + lazy: true, + ), ], child: MaterialApp( title: 'FMTC Demo', diff --git a/example/lib/src/screens/configure_download/components/region_information.dart b/example/lib/src/screens/configure_download/components/region_information.dart index b661e49e..de62ac63 100644 --- a/example/lib/src/screens/configure_download/components/region_information.dart +++ b/example/lib/src/screens/configure_download/components/region_information.dart @@ -12,17 +12,11 @@ class RegionInformation extends StatefulWidget { const RegionInformation({ super.key, required this.region, - required this.minZoom, - required this.maxZoom, - required this.startTile, - required this.endTile, + required this.maxTiles, }); - final BaseRegion region; - final int minZoom; - final int maxZoom; - final int startTile; - final int? endTile; + final DownloadableRegion region; + final int? maxTiles; @override State createState() => _RegionInformationState(); @@ -31,20 +25,6 @@ class RegionInformation extends StatefulWidget { class _RegionInformationState extends State { final distance = const Distance(roundResult: false).distance; - late Future numOfTiles; - - @override - void initState() { - super.initState(); - numOfTiles = const FMTCStore('').download.check( - widget.region.toDownloadable( - minZoom: widget.minZoom, - maxZoom: widget.maxZoom, - options: TileLayer(), - ), - ); - } - @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -57,63 +37,73 @@ class _RegionInformationState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ ...widget.region.when( - rectangle: (rectangle) => [ - const Text('TOTAL AREA'), - Text( - '${(distance(rectangle.bounds.northWest, rectangle.bounds.northEast) * distance(rectangle.bounds.northEast, rectangle.bounds.southEast) / 1000000).toStringAsFixed(3)} km²', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, + rectangle: (rectangleRegion) { + final rectangle = rectangleRegion.originalRegion; + + return [ + const Text('TOTAL AREA'), + Text( + '${(distance(rectangle.bounds.northWest, rectangle.bounds.northEast) * distance(rectangle.bounds.northEast, rectangle.bounds.southEast) / 1000000).toStringAsFixed(3)} km²', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), ), - ), - const SizedBox(height: 10), - const Text('APPROX. NORTH WEST'), - Text( - '${rectangle.bounds.northWest.latitude.toStringAsFixed(3)}, ${rectangle.bounds.northWest.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, + const SizedBox(height: 10), + const Text('APPROX. NORTH WEST'), + Text( + '${rectangle.bounds.northWest.latitude.toStringAsFixed(3)}, ${rectangle.bounds.northWest.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), ), - ), - const SizedBox(height: 10), - const Text('APPROX. SOUTH EAST'), - Text( - '${rectangle.bounds.southEast.latitude.toStringAsFixed(3)}, ${rectangle.bounds.southEast.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, + const SizedBox(height: 10), + const Text('APPROX. SOUTH EAST'), + Text( + '${rectangle.bounds.southEast.latitude.toStringAsFixed(3)}, ${rectangle.bounds.southEast.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), ), - ), - ], - circle: (circle) => [ - const Text('TOTAL AREA'), - Text( - '${(pi * pow(circle.radius, 2)).toStringAsFixed(3)} km²', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, + ]; + }, + circle: (circleRegion) { + final circle = circleRegion.originalRegion; + + return [ + const Text('TOTAL AREA'), + Text( + '${(pi * pow(circle.radius, 2)).toStringAsFixed(3)} km²', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), ), - ), - const SizedBox(height: 10), - const Text('RADIUS'), - Text( - '${circle.radius.toStringAsFixed(2)} km', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, + const SizedBox(height: 10), + const Text('RADIUS'), + Text( + '${circle.radius.toStringAsFixed(2)} km', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), ), - ), - const SizedBox(height: 10), - const Text('APPROX. CENTER'), - Text( - '${circle.center.latitude.toStringAsFixed(3)}, ${circle.center.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, + const SizedBox(height: 10), + const Text('APPROX. CENTER'), + Text( + '${circle.center.latitude.toStringAsFixed(3)}, ${circle.center.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), ), - ), - ], - line: (line) { + ]; + }, + line: (lineRegion) { + final line = lineRegion.originalRegion; + double totalDistance = 0; for (int i = 0; i < line.line.length - 1; i++) { @@ -150,7 +140,9 @@ class _RegionInformationState extends State { ), ]; }, - customPolygon: (customPolygon) { + customPolygon: (customPolygonRegion) { + final customPolygon = customPolygonRegion.originalRegion; + double area = 0; for (final triangle in Earcut.triangulateFromPoints( @@ -186,7 +178,7 @@ class _RegionInformationState extends State { children: [ const Text('ZOOM LEVELS'), Text( - '${widget.minZoom} - ${widget.maxZoom}', + '${widget.region.minZoom} - ${widget.region.maxZoom}', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 24, @@ -194,37 +186,34 @@ class _RegionInformationState extends State { ), const SizedBox(height: 10), const Text('TOTAL TILES'), - FutureBuilder( - future: numOfTiles, - builder: (context, snapshot) => snapshot.data == null - ? Padding( - padding: const EdgeInsets.only(top: 4), - child: SizedBox( - height: 36, - width: 36, - child: Center( - child: SizedBox( - height: 28, - width: 28, - child: CircularProgressIndicator( - color: - Theme.of(context).colorScheme.secondary, - ), - ), - ), - ), - ) - : Text( - NumberFormat('###,###').format(snapshot.data), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, + if (widget.maxTiles case final maxTiles?) + Text( + NumberFormat('###,###').format(maxTiles), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ) + else + Padding( + padding: const EdgeInsets.only(top: 4), + child: SizedBox( + height: 36, + width: 36, + child: Center( + child: SizedBox( + height: 28, + width: 28, + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.secondary, ), ), - ), + ), + ), + ), const SizedBox(height: 10), const Text('TILES RANGE'), - if (widget.startTile == 1 && widget.endTile == null) + if (widget.region.start == 1 && widget.region.end == null) const Text( '*', style: TextStyle( @@ -234,7 +223,7 @@ class _RegionInformationState extends State { ) else Text( - '${NumberFormat('###,###').format(widget.startTile)} - ${widget.endTile != null ? NumberFormat('###,###').format(widget.endTile) : '*'}', + '${NumberFormat('###,###').format(widget.region.start)} - ${widget.region.end != null ? NumberFormat('###,###').format(widget.region.end) : '*'}', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 24, @@ -244,6 +233,51 @@ class _RegionInformationState extends State { ), ], ), + 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.', + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), ], ); } diff --git a/example/lib/src/screens/configure_download/components/start_download_button.dart b/example/lib/src/screens/configure_download/components/start_download_button.dart index f426d87a..73f65e4b 100644 --- a/example/lib/src/screens/configure_download/components/start_download_button.dart +++ b/example/lib/src/screens/configure_download/components/start_download_button.dart @@ -1,134 +1,103 @@ +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'; import 'package:provider/provider.dart'; -import '../../home/map_view/state/region_selection_provider.dart'; +import '../../../shared/misc/store_metadata_keys.dart'; +import '../../download/download.dart'; import '../state/configure_download_provider.dart'; class StartDownloadButton extends StatelessWidget { const StartDownloadButton({ super.key, required this.region, - required this.minZoom, - required this.maxZoom, - required this.startTile, - required this.endTile, + required this.maxTiles, }); - final BaseRegion region; - final int minZoom; - final int maxZoom; - final int startTile; - final int? endTile; + final DownloadableRegion region; + final int? maxTiles; @override Widget build(BuildContext context) => - Selector( - selector: (context, provider) => provider.isReady, - builder: (context, isReady, _) => - Selector( - selector: (context, provider) => provider.selectedStore, - builder: (context, selectedStore, child) => IgnorePointer( - ignoring: selectedStore == null, + Selector( + selector: (context, provider) => provider.selectedStore, + builder: (context, selectedStore, child) { + final enabled = selectedStore != null && maxTiles != null; + + return IgnorePointer( + ignoring: !enabled, child: AnimatedOpacity( - opacity: selectedStore == null ? 0 : 1, + opacity: enabled ? 1 : 0, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, child: child, ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - AnimatedScale( - scale: isReady ? 1 : 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInCubic, - alignment: Alignment.bottomRight, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurface, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - bottomLeft: Radius.circular(12), - ), - ), - margin: const EdgeInsets.only(right: 12, left: 32), - padding: const EdgeInsets.all(12), - constraints: const BoxConstraints(maxWidth: 500), - child: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - "You must abide by your tile server's Terms of Service when bulk downloading. 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.", - textAlign: TextAlign.end, - style: TextStyle(color: Colors.black), - ), - SizedBox(height: 8), - Icon(Icons.report, color: Colors.red, size: 32), - ], - ), - ), - ), - const SizedBox(height: 16), - FloatingActionButton.extended( - onPressed: () async { - final configureDownloadProvider = - context.read(); + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + FloatingActionButton.extended( + onPressed: () async { + final configureDownloadProvider = + context.read(); - if (!isReady) { - configureDownloadProvider.isReady = true; - return; - } - - final regionSelectionProvider = - context.read(); - final downloadingProvider = - context.read(); + if (!await configureDownloadProvider + .selectedStore!.manage.ready && + context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Selected store no longer exists'), + ), + ); + return; + } - final navigator = Navigator.of(context); + final urlTemplate = (await configureDownloadProvider + .selectedStore! + .metadata + .read)[StoreMetadataKeys.urlTemplate.key]!; - final metadata = await regionSelectionProvider - .selectedStore!.metadata.read; + if (!context.mounted) return; - downloadingProvider.setDownloadProgress( - regionSelectionProvider.selectedStore!.download - .startForeground( - region: region.toDownloadable( - minZoom: minZoom, - maxZoom: maxZoom, - start: startTile, - end: endTile, - options: TileLayer( - urlTemplate: metadata['sourceURL'], - userAgentPackageName: - 'dev.jaffaketchup.fmtc.demo', - ), + unawaited( + Navigator.of(context).popAndPushNamed( + DownloadPopup.route, + arguments: ( + downloadProgress: configureDownloadProvider + .selectedStore!.download + .startForeground( + region: region.originalRegion.toDownloadable( + minZoom: region.minZoom, + maxZoom: region.maxZoom, + start: region.start, + end: region.end, + options: TileLayer( + urlTemplate: urlTemplate, + userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', ), - parallelThreads: - configureDownloadProvider.parallelThreads, - maxBufferLength: - configureDownloadProvider.maxBufferLength, - skipExistingTiles: - configureDownloadProvider.skipExistingTiles, - skipSeaTiles: configureDownloadProvider.skipSeaTiles, - rateLimit: configureDownloadProvider.rateLimit, - ) - .asBroadcastStream(), - ); - configureDownloadProvider.isReady = false; - - navigator.pop(); - }, - label: const Text('Start Download'), - icon: Icon(isReady ? Icons.save : Icons.arrow_forward), - ), - ], - ), + ), + parallelThreads: + configureDownloadProvider.parallelThreads, + maxBufferLength: + configureDownloadProvider.maxBufferLength, + skipExistingTiles: + configureDownloadProvider.skipExistingTiles, + skipSeaTiles: configureDownloadProvider.skipSeaTiles, + rateLimit: configureDownloadProvider.rateLimit, + ), + maxTiles: maxTiles! + ), + ), + ); + }, + label: const Text('Start Download'), + icon: const Icon(Icons.save), + ), + ], ), ); } diff --git a/example/lib/src/screens/configure_download/components/store_selector.dart b/example/lib/src/screens/configure_download/components/store_selector.dart index e63a6fbb..574f985e 100644 --- a/example/lib/src/screens/configure_download/components/store_selector.dart +++ b/example/lib/src/screens/configure_download/components/store_selector.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../shared/state/general_provider.dart'; -import '../../home/map_view/state/region_selection_provider.dart'; +import '../state/configure_download_provider.dart'; class StoreSelector extends StatefulWidget { const StoreSelector({super.key}); @@ -19,34 +18,36 @@ class _StoreSelectorState extends State { const Text('Store'), const Spacer(), IntrinsicWidth( - child: Consumer2( - builder: (context, downloadProvider, generalProvider, _) => + child: Selector( + selector: (context, provider) => provider.selectedStore, + builder: (context, selectedStore, _) => FutureBuilder>( future: FMTCRoot.stats.storesAvailable, - builder: (context, snapshot) => DropdownButton( - items: snapshot.data + builder: (context, snapshot) { + final items = snapshot.data ?.map( (e) => DropdownMenuItem( value: e, child: Text(e.storeName), ), ) - .toList(), - onChanged: (store) => - downloadProvider.setSelectedStore(store), - value: downloadProvider.selectedStore ?? - (generalProvider.currentStores.length == 1 - ? null - : FMTCStore(generalProvider.currentStores.single)), - hint: Text( - snapshot.data == null - ? 'Loading...' - : snapshot.data!.isEmpty - ? 'None Available' - : 'None Selected', - ), - padding: const EdgeInsets.only(left: 12), - ), + .toList(); + final text = snapshot.data == null + ? 'Loading...' + : snapshot.data!.isEmpty + ? 'None Available' + : 'None Selected'; + + return DropdownButton( + items: items, + onChanged: (store) => context + .read() + .selectedStore = store, + value: selectedStore, + hint: Text(text), + padding: const EdgeInsets.only(left: 12), + ); + }, ), ), ), diff --git a/example/lib/src/screens/configure_download/configure_download.dart b/example/lib/src/screens/configure_download/configure_download.dart index 7ed25b95..52209767 100644 --- a/example/lib/src/screens/configure_download/configure_download.dart +++ b/example/lib/src/screens/configure_download/configure_download.dart @@ -1,8 +1,10 @@ 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:provider/provider.dart'; import '../../shared/misc/exts/interleave.dart'; +import '../home/map_view/state/region_selection_provider.dart'; import 'components/numerical_input_row.dart'; import 'components/options_pane.dart'; import 'components/region_information.dart'; @@ -10,151 +12,136 @@ import 'components/start_download_button.dart'; import 'components/store_selector.dart'; import 'state/configure_download_provider.dart'; -class ConfigureDownloadPopup extends StatelessWidget { - const ConfigureDownloadPopup({ - super.key, - required this.region, - required this.minZoom, - required this.maxZoom, - required this.startTile, - required this.endTile, - }); +class ConfigureDownloadPopup extends StatefulWidget { + const ConfigureDownloadPopup({super.key}); - final BaseRegion region; - final int minZoom; - final int maxZoom; - final int startTile; - final int? endTile; + static const String route = '/download/configure'; + + @override + State createState() => _ConfigureDownloadPopupState(); +} + +class _ConfigureDownloadPopupState extends State { + DownloadableRegion? region; + int? maxTiles; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final provider = context.read(); + const FMTCStore('') + .download + .check( + region ??= provider.region!.toDownloadable( + minZoom: provider.minZoom, + maxZoom: provider.maxZoom, + start: provider.startTile, + end: provider.endTile, + options: TileLayer(), + ), + ) + .then((v) => setState(() => maxTiles = v)); + } @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: const Text('Configure Bulk Download')), floatingActionButton: StartDownloadButton( - region: region, - minZoom: minZoom, - maxZoom: maxZoom, - startTile: startTile, - endTile: endTile, + region: region!, + maxTiles: maxTiles, ), - body: Stack( - fit: StackFit.expand, - children: [ - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox.shrink(), + RegionInformation( + region: region!, + maxTiles: maxTiles, + ), + const Divider(thickness: 2, height: 8), + const OptionsPane( + label: 'STORE DIRECTORY', + children: [StoreSelector()], + ), + OptionsPane( + label: 'PERFORMANCE FACTORS', children: [ - const SizedBox.shrink(), - RegionInformation( - region: region, - minZoom: minZoom, - maxZoom: maxZoom, - startTile: startTile, - endTile: endTile, + NumericalInputRow( + label: 'Parallel Threads', + suffixText: 'threads', + value: (provider) => provider.parallelThreads, + min: 1, + max: 10, + onChanged: (provider, value) => + provider.parallelThreads = value, ), - const Divider(thickness: 2, height: 8), - const OptionsPane( - label: 'STORE DIRECTORY', - children: [StoreSelector()], + NumericalInputRow( + label: 'Rate Limit', + suffixText: 'max. tps', + value: (provider) => provider.rateLimit, + min: 1, + max: 300, + maxEligibleTilesPreview: 20, + onChanged: (provider, value) => + provider.rateLimit = value, ), - OptionsPane( - label: 'PERFORMANCE FACTORS', + NumericalInputRow( + label: 'Tile Buffer Length', + suffixText: 'max. tiles', + value: (provider) => provider.maxBufferLength, + min: 0, + max: null, + onChanged: (provider, value) => + provider.maxBufferLength = value, + ), + ], + ), + OptionsPane( + label: 'SKIP TILES', + children: [ + Row( children: [ - NumericalInputRow( - label: 'Parallel Threads', - suffixText: 'threads', - value: (provider) => provider.parallelThreads, - min: 1, - max: 10, - onChanged: (provider, value) => - provider.parallelThreads = value, - ), - NumericalInputRow( - label: 'Rate Limit', - suffixText: 'max. tps', - value: (provider) => provider.rateLimit, - min: 1, - max: 300, - maxEligibleTilesPreview: 20, - onChanged: (provider, value) => - provider.rateLimit = value, - ), - NumericalInputRow( - label: 'Tile Buffer Length', - suffixText: 'max. tiles', - value: (provider) => provider.maxBufferLength, - min: 0, - max: null, - onChanged: (provider, value) => - provider.maxBufferLength = value, + const Text('Skip Existing Tiles'), + const Spacer(), + Switch.adaptive( + value: + context.select( + (provider) => provider.skipExistingTiles, + ), + onChanged: (val) => context + .read() + .skipExistingTiles = val, + activeColor: Theme.of(context).colorScheme.primary, ), ], ), - OptionsPane( - label: 'SKIP TILES', + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - const Text('Skip Existing Tiles'), - const Spacer(), - Switch.adaptive( - value: context - .select( - (provider) => provider.skipExistingTiles, - ), - onChanged: (val) => context - .read() - .skipExistingTiles = val, - activeColor: - Theme.of(context).colorScheme.primary, - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Skip Sea Tiles'), - const Spacer(), - Switch.adaptive( - value: context.select((provider) => provider.skipSeaTiles), - onChanged: (val) => context - .read() - .skipSeaTiles = val, - activeColor: - Theme.of(context).colorScheme.primary, - ), - ], + const Text('Skip Sea Tiles'), + const Spacer(), + Switch.adaptive( + value: + context.select( + (provider) => provider.skipSeaTiles, + ), + onChanged: (val) => context + .read() + .skipSeaTiles = val, + activeColor: Theme.of(context).colorScheme.primary, ), ], ), - const SizedBox(height: 72), - ].interleave(const SizedBox.square(dimension: 16)).toList(), - ), - ), - ), - Selector( - selector: (context, provider) => provider.isReady, - builder: (context, isReady, _) => IgnorePointer( - ignoring: !isReady, - child: GestureDetector( - onTap: isReady - ? () => context - .read() - .isReady = false - : null, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInCubic, - color: isReady - ? Colors.black.withOpacity(2 / 3) - : Colors.transparent, - ), + ], ), - ), + const SizedBox(height: 72), + ].interleave(const SizedBox.square(dimension: 16)).toList(), ), - ], + ), ), ); } diff --git a/example/lib/src/screens/configure_download/state/configure_download_provider.dart b/example/lib/src/screens/configure_download/state/configure_download_provider.dart index 5afffaa2..4981bf35 100644 --- a/example/lib/src/screens/configure_download/state/configure_download_provider.dart +++ b/example/lib/src/screens/configure_download/state/configure_download_provider.dart @@ -1,10 +1,13 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; class ConfigureDownloadProvider extends ChangeNotifier { static const defaultValues = ( parallelThreads: 3, rateLimit: 200, maxBufferLength: 200, + skipExistingTiles: false, + skipSeaTiles: true, ); int _parallelThreads = defaultValues.parallelThreads; @@ -28,24 +31,24 @@ class ConfigureDownloadProvider extends ChangeNotifier { notifyListeners(); } - bool _skipExistingTiles = true; + bool _skipExistingTiles = defaultValues.skipExistingTiles; bool get skipExistingTiles => _skipExistingTiles; set skipExistingTiles(bool newState) { _skipExistingTiles = newState; notifyListeners(); } - bool _skipSeaTiles = true; + bool _skipSeaTiles = defaultValues.skipSeaTiles; bool get skipSeaTiles => _skipSeaTiles; set skipSeaTiles(bool newState) { _skipSeaTiles = newState; notifyListeners(); } - bool _isReady = false; - bool get isReady => _isReady; - set isReady(bool newState) { - _isReady = newState; + FMTCStore? _selectedStore; + FMTCStore? get selectedStore => _selectedStore; + set selectedStore(FMTCStore? newStore) { + _selectedStore = newStore; notifyListeners(); } } diff --git a/example/lib/src/screens/download/components/confirm_cancellation_dialog.dart b/example/lib/src/screens/download/components/confirm_cancellation_dialog.dart new file mode 100644 index 00000000..0f0c9965 --- /dev/null +++ b/example/lib/src/screens/download/components/confirm_cancellation_dialog.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../configure_download/state/configure_download_provider.dart'; + +class ConfirmCancellationDialog extends StatefulWidget { + const ConfirmCancellationDialog({super.key}); + + @override + State createState() => + _ConfirmCancellationDialogState(); +} + +class _ConfirmCancellationDialogState extends State { + bool isCancelling = false; + + @override + Widget build(BuildContext context) => AlertDialog.adaptive( + icon: const Icon(Icons.cancel), + title: const Text('Cancel download?'), + content: const Text('Any tiles already downloaded will not be removed'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Continue download'), + ), + if (isCancelling) + const CircularProgressIndicator.adaptive() + else + FilledButton( + onPressed: () async { + setState(() => isCancelling = true); + await context + .read() + .selectedStore! + .download + .cancel(); + if (context.mounted) Navigator.of(context).pop(true); + }, + child: const Text('Cancel download'), + ), + ], + ); +} diff --git a/example/lib/src/screens/download/components/main_statistics.dart b/example/lib/src/screens/download/components/main_statistics.dart new file mode 100644 index 00000000..850c1f3b --- /dev/null +++ b/example/lib/src/screens/download/components/main_statistics.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../configure_download/state/configure_download_provider.dart'; +import 'stat_display.dart'; + +class MainStatistics extends StatefulWidget { + const MainStatistics({ + super.key, + required this.download, + required this.maxTiles, + }); + + final DownloadProgress? download; + final int maxTiles; + + @override + State createState() => _MainStatisticsState(); +} + +class _MainStatisticsState extends State { + @override + Widget build(BuildContext context) => IntrinsicWidth( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RepaintBoundary( + child: Text( + '${widget.download?.attemptedTiles ?? 0}/${widget.maxTiles} (${widget.download?.percentageProgress.toStringAsFixed(2) ?? 0}%)', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 16), + StatDisplay( + statistic: + '${widget.download?.elapsedDuration.toString().split('.')[0] ?? '0:00:00'} ' + '/ ${widget.download?.estTotalDuration.toString().split('.')[0] ?? '0:00:00'}', + description: 'elapsed / estimated total duration', + ), + StatDisplay( + statistic: widget.download?.estRemainingDuration + .toString() + .split('.')[0] ?? + '0:00:00', + description: 'estimated remaining duration', + ), + RepaintBoundary( + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.download?.tilesPerSecond.toStringAsFixed(2) ?? + '...', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: + widget.download?.isTPSArtificiallyCapped ?? false + ? Colors.amber + : null, + ), + ), + if (widget.download?.isTPSArtificiallyCapped ?? + false) ...[ + const SizedBox(width: 8), + const Icon(Icons.lock_clock, color: Colors.amber), + ], + ], + ), + Text( + 'approx. tiles per second', + style: TextStyle( + fontSize: 16, + color: widget.download?.isTPSArtificiallyCapped ?? false + ? Colors.amber + : null, + ), + ), + ], + ), + ), + 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, + ), + ), + const SizedBox(width: 12), + IconButton.outlined( + onPressed: () => selectedStore.download.cancel(), + icon: const Icon(Icons.cancel), + ), + ], + ), + ), + ), + if (widget.download?.isComplete ?? false) + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text('Exit'), + ), + ), + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/download/components/multi_linear_progress_indicator.dart b/example/lib/src/screens/download/components/multi_linear_progress_indicator.dart new file mode 100644 index 00000000..1881fc65 --- /dev/null +++ b/example/lib/src/screens/download/components/multi_linear_progress_indicator.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +typedef IndividualProgress = ({num value, Color color, Widget? child}); + +class MulitLinearProgressIndicator extends StatefulWidget { + const MulitLinearProgressIndicator({ + super.key, + required this.progresses, + this.maxValue = 1, + this.backgroundChild, + this.height = 24, + this.radius, + this.childAlignment = Alignment.centerRight, + this.animationDuration = const Duration(milliseconds: 500), + }); + + final List progresses; + final num maxValue; + final Widget? backgroundChild; + final double height; + final BorderRadiusGeometry? radius; + final AlignmentGeometry childAlignment; + final Duration animationDuration; + + @override + State createState() => + _MulitLinearProgressIndicatorState(); +} + +class _MulitLinearProgressIndicatorState + extends State { + @override + Widget build(BuildContext context) => RepaintBoundary( + child: LayoutBuilder( + builder: (context, constraints) => ClipRRect( + borderRadius: + widget.radius ?? BorderRadius.circular(widget.height / 2), + child: SizedBox( + height: widget.height, + width: constraints.maxWidth, + child: Stack( + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: widget.radius ?? + BorderRadius.circular(widget.height / 2), + border: Border.all( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + padding: EdgeInsets.symmetric( + vertical: 2, + horizontal: widget.height / 2, + ), + alignment: widget.childAlignment, + child: widget.backgroundChild, + ), + ), + ...widget.progresses.map( + (e) => AnimatedPositioned( + height: widget.height, + left: 0, + width: (constraints.maxWidth / widget.maxValue) * e.value, + duration: widget.animationDuration, + child: Container( + decoration: BoxDecoration( + color: e.color, + borderRadius: widget.radius ?? + BorderRadius.circular(widget.height / 2), + ), + padding: EdgeInsets.symmetric( + vertical: 2, + horizontal: widget.height / 2, + ), + alignment: widget.childAlignment, + child: e.child, + ), + ), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/example/lib/src/screens/download/components/stat_display.dart b/example/lib/src/screens/download/components/stat_display.dart new file mode 100644 index 00000000..3592c850 --- /dev/null +++ b/example/lib/src/screens/download/components/stat_display.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class StatDisplay extends StatelessWidget { + const StatDisplay({ + super.key, + required this.statistic, + required this.description, + }); + + final String statistic; + final String description; + + @override + Widget build(BuildContext context) => RepaintBoundary( + child: Column( + children: [ + Text( + statistic, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + Text( + description, + style: const TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + ], + ), + ); +} diff --git a/example/lib/src/screens/download/components/stats_table.dart b/example/lib/src/screens/download/components/stats_table.dart new file mode 100644 index 00000000..09d8bd12 --- /dev/null +++ b/example/lib/src/screens/download/components/stats_table.dart @@ -0,0 +1,86 @@ +part of '../download.dart'; + +class _StatsTable extends StatelessWidget { + const _StatsTable({ + required this.download, + }); + + final DownloadProgress? download; + + @override + Widget build(BuildContext context) => Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + children: [ + StatDisplay( + statistic: + '${(download?.cachedTiles ?? 0) - (download?.bufferedTiles ?? 0)} + ${download?.bufferedTiles ?? 0}', + description: 'cached + buffered tiles', + ), + StatDisplay( + statistic: + '${(((download?.cachedSize ?? 0) - (download?.bufferedSize ?? 0)) * 1024).asReadableSize} + ${((download?.bufferedSize ?? 0) * 1024).asReadableSize}', + description: 'cached + buffered size', + ), + ], + ), + TableRow( + children: [ + StatDisplay( + statistic: + '${download?.skippedTiles ?? 0} (${(download?.skippedTiles ?? 0) == 0 ? 0 : (100 - (((download?.cachedTiles ?? 0) - (download?.skippedTiles ?? 0)) / (download?.cachedTiles ?? 0)) * 100).clamp(double.minPositive, 100).toStringAsFixed(1)}%)', + description: 'skipped tiles (% saving)', + ), + StatDisplay( + statistic: + '${((download?.skippedSize ?? 0) * 1024).asReadableSize} (${(download?.skippedTiles ?? 0) == 0 ? 0 : (100 - (((download?.cachedSize ?? 0) - (download?.skippedSize ?? 0)) / (download?.cachedSize ?? 0)) * 100).clamp(double.minPositive, 100).toStringAsFixed(1)}%)', + description: 'skipped size (% saving)', + ), + ], + ), + TableRow( + children: [ + RepaintBoundary( + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + download?.failedTiles.toString() ?? '0', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: (download?.failedTiles ?? 0) == 0 + ? null + : Colors.red, + ), + ), + if ((download?.failedTiles ?? 0) != 0) ...[ + const SizedBox(width: 8), + const Icon( + Icons.warning_amber, + color: Colors.red, + ), + ], + ], + ), + Text( + 'failed tiles', + style: TextStyle( + fontSize: 16, + color: (download?.failedTiles ?? 0) == 0 + ? null + : Colors.red, + ), + ), + ], + ), + ), + const SizedBox.shrink(), + ], + ), + ], + ); +} diff --git a/example/lib/src/screens/download/download.dart b/example/lib/src/screens/download/download.dart new file mode 100644 index 00000000..f6f6e028 --- /dev/null +++ b/example/lib/src/screens/download/download.dart @@ -0,0 +1,343 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +import '../../shared/misc/exts/size_formatter.dart'; +import 'components/confirm_cancellation_dialog.dart'; +import 'components/main_statistics.dart'; +import 'components/multi_linear_progress_indicator.dart'; +import 'components/stat_display.dart'; + +part 'components/stats_table.dart'; + +class DownloadPopup extends StatefulWidget { + const DownloadPopup({super.key}); + + static const String route = '/download/progress'; + + @override + State createState() => _DownloadPopupState(); +} + +class _DownloadPopupState extends State { + bool isInitialised = false; + + late final Stream downloadProgress; + late final StreamSubscription dpSubscription; + late final int maxTiles; + + final failedTiles = []; + final skippedTiles = []; + bool isCompleteCanPop = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (!isInitialised) { + final arguments = ModalRoute.of(context)!.settings.arguments! as ({ + Stream downloadProgress, + int maxTiles + }); + downloadProgress = arguments.downloadProgress.asBroadcastStream(); + dpSubscription = downloadProgress.listen((progress) { + if (progress.latestTileEvent.isRepeat) return; + if (progress.latestTileEvent.result.category == + TileEventResultCategory.failed) { + failedTiles.add(progress.latestTileEvent); + } + if (progress.latestTileEvent.result.category == + TileEventResultCategory.skipped) { + skippedTiles.add(progress.latestTileEvent); + } + isCompleteCanPop = progress.isComplete; + }); + maxTiles = arguments.maxTiles; + } + + isInitialised = true; + } + + @override + void dispose() { + dpSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: isCompleteCanPop, + onPopInvokedWithResult: (didPop, result) async { + if (!didPop && + await showDialog( + context: context, + builder: (context) => const ConfirmCancellationDialog(), + ) as bool && + context.mounted) Navigator.of(context).pop(); + }, + child: Scaffold( + appBar: AppBar( + title: const Text('Downloading Region'), + ), + body: Padding( + padding: const EdgeInsets.all(12), + child: StreamBuilder( + stream: downloadProgress, + builder: (context, snapshot) { + final download = snapshot.data; + + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 800; + + return SingleChildScrollView( + child: Column( + children: [ + IntrinsicHeight( + child: Flex( + direction: + isWide ? Axis.horizontal : Axis.vertical, + children: [ + Expanded( + child: Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: + WrapCrossAlignment.center, + spacing: 32, + runSpacing: 28, + children: [ + RepaintBoundary( + child: SizedBox.square( + dimension: isWide ? 216 : 196, + child: ClipRRect( + borderRadius: + BorderRadius.circular(16), + child: download?.latestTileEvent + .tileImage != + null + ? Image.memory( + download!.latestTileEvent + .tileImage!, + gaplessPlayback: true, + ) + : const Center( + child: + CircularProgressIndicator + .adaptive(), + ), + ), + ), + ), + MainStatistics( + download: download, + maxTiles: maxTiles, + ), + ], + ), + ), + const SizedBox.square(dimension: 16), + if (isWide) + const VerticalDivider() + else + const Divider(), + const SizedBox.square(dimension: 16), + if (isWide) + Expanded( + child: _StatsTable(download: download), + ) + else + _StatsTable(download: download), + ], + ), + ), + const SizedBox(height: 30), + MulitLinearProgressIndicator( + maxValue: maxTiles, + backgroundChild: Text( + '${download?.remainingTiles ?? 0}', + style: const TextStyle(color: Colors.white), + ), + progresses: [ + ( + value: (download?.cachedTiles ?? 0) + + (download?.skippedTiles ?? 0) + + (download?.failedTiles ?? 0), + color: Colors.red, + child: Text( + '${download?.failedTiles ?? 0}', + style: const TextStyle(color: Colors.black), + ) + ), + ( + value: (download?.cachedTiles ?? 0) + + (download?.skippedTiles ?? 0), + color: Colors.yellow, + child: Text( + '${download?.skippedTiles ?? 0}', + style: const TextStyle(color: Colors.black), + ) + ), + ( + value: download?.cachedTiles ?? 0, + color: Colors.green[300]!, + child: Text( + '${download?.bufferedTiles ?? 0}', + style: const TextStyle(color: Colors.black), + ) + ), + ( + value: (download?.cachedTiles ?? 0) - + (download?.bufferedTiles ?? 0), + color: Colors.green, + child: Text( + '${(download?.cachedTiles ?? 0) - (download?.bufferedTiles ?? 0)}', + style: const TextStyle(color: Colors.black), + ) + ), + ], + ), + const SizedBox(height: 32), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RotatedBox( + quarterTurns: 3, + child: Text( + 'FAILED TILES', + ), + ), + Expanded( + child: RepaintBoundary( + child: failedTiles.isEmpty + ? const Padding( + padding: EdgeInsets.symmetric( + horizontal: 2, + ), + child: Column( + children: [ + Icon(Icons.task_alt, size: 48), + SizedBox(height: 10), + Text( + 'Any failed tiles will appear here', + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView.builder( + reverse: true, + addRepaintBoundaries: false, + itemCount: failedTiles.length, + shrinkWrap: true, + itemBuilder: (context, index) => + ListTile( + leading: Icon( + switch ( + failedTiles[index].result) { + TileEventResult + .noConnectionDuringFetch => + Icons.wifi_off, + TileEventResult + .unknownFetchException => + Icons.error, + TileEventResult + .negativeFetchResponse => + Icons.reply, + _ => Icons.abc, + }, + ), + title: Text(failedTiles[index].url), + subtitle: Text( + switch ( + failedTiles[index].result) { + TileEventResult + .noConnectionDuringFetch => + 'Failed to establish a connection to the network', + TileEventResult + .unknownFetchException => + 'There was an unknown error when trying to download this tile, of type ${failedTiles[index].fetchError.runtimeType}', + TileEventResult + .negativeFetchResponse => + 'The tile server responded with an HTTP status code of ${failedTiles[index].fetchResponse!.statusCode} (${failedTiles[index].fetchResponse!.reasonPhrase})', + _ => throw Error(), + }, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + const RotatedBox( + quarterTurns: 3, + child: Text( + 'SKIPPED TILES', + ), + ), + Expanded( + child: RepaintBoundary( + child: skippedTiles.isEmpty + ? const Padding( + padding: EdgeInsets.symmetric( + horizontal: 2, + ), + child: Column( + children: [ + Icon(Icons.task_alt, size: 48), + SizedBox(height: 10), + Text( + 'Any skipped tiles will appear here', + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView.builder( + reverse: true, + addRepaintBoundaries: false, + itemCount: skippedTiles.length, + shrinkWrap: true, + itemBuilder: (context, index) => + ListTile( + leading: Icon( + switch ( + skippedTiles[index].result) { + TileEventResult + .alreadyExisting => + Icons.disabled_visible, + TileEventResult.isSeaTile => + Icons.water_drop, + _ => Icons.abc, + }, + ), + title: + Text(skippedTiles[index].url), + subtitle: Text( + switch ( + skippedTiles[index].result) { + TileEventResult + .alreadyExisting => + 'Tile already exists', + TileEventResult.isSeaTile => + 'Tile is a sea tile', + _ => throw Error(), + }, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ); + }, + ); + }, + ), + ), + ), + ); +} diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart index 87c717f8..16365970 100644 --- a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart +++ b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart @@ -8,6 +8,7 @@ import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../../../../../../shared/misc/exts/interleave.dart'; +import '../../../../../configure_download/configure_download.dart'; import '../../../state/region_selection_provider.dart'; part 'additional_panes/additional_pane.dart'; @@ -30,9 +31,8 @@ class RegionSelectionSidePanel extends StatelessWidget { bottomPaddingWrapperBuilder; final Axis layoutDirection; - void finalizeSelection(BuildContext context) { - throw UnimplementedError(); - } + void finalizeSelection(BuildContext context) => + Navigator.of(context).pushNamed(ConfigureDownloadPopup.route); @override Widget build(BuildContext context) { diff --git a/example/lib/src/screens/home/map_view/state/region_selection_provider.dart b/example/lib/src/screens/home/map_view/state/region_selection_provider.dart index 8eb62d2e..0a988e98 100644 --- a/example/lib/src/screens/home/map_view/state/region_selection_provider.dart +++ b/example/lib/src/screens/home/map_view/state/region_selection_provider.dart @@ -129,11 +129,4 @@ class RegionSelectionProvider extends ChangeNotifier { _endTile = endTile; notifyListeners(); } - - FMTCStore? _selectedStore; - FMTCStore? get selectedStore => _selectedStore; - void setSelectedStore(FMTCStore? newStore, {bool notify = true}) { - _selectedStore = newStore; - if (notify) notifyListeners(); - } } From c96cb73080b7d6b600198b084a893e3cb4f9b1bd Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 15 Aug 2024 14:47:32 +0100 Subject: [PATCH 38/97] Added `MultiRegion` Added `maybeWhen` method to `DownloadableRegion` & `BaseRegion` Improved internal stability by improving type safety when communicating with `TileGenerators` & `TileCounters` --- CHANGELOG.md | 6 +- .../components/region_information.dart | 3 + lib/flutter_map_tile_caching.dart | 1 + .../impls/objectbox/backend/backend.dart | 1 + .../internal_workers/standard/worker.dart | 34 +- .../backend/internal_workers/thread_safe.dart | 28 +- .../models/generated/objectbox-model.json | 178 ++++--- .../models/generated/objectbox.g.dart | 483 +++++++++++------- .../impls/objectbox/models/src/recovery.dart | 187 +------ .../objectbox/models/src/recovery_region.dart | 163 ++++++ lib/src/bulk_download/internal/manager.dart | 26 +- .../internal/rate_limited_stream.dart | 1 - .../internal/tile_loops/count.dart | 43 +- .../internal/tile_loops/generate.dart | 239 +++++++-- lib/src/regions/base_region.dart | 35 +- lib/src/regions/downloadable_region.dart | 33 +- lib/src/regions/recovered_region.dart | 50 +- lib/src/regions/shapes/circle.dart | 11 +- lib/src/regions/shapes/custom_polygon.dart | 13 +- lib/src/regions/shapes/line.dart | 13 +- lib/src/regions/shapes/multi.dart | 54 ++ lib/src/regions/shapes/rectangle.dart | 13 +- lib/src/store/download.dart | 11 +- pubspec.yaml | 14 +- test/region_tile_test.dart | 40 +- 25 files changed, 1063 insertions(+), 617 deletions(-) create mode 100644 lib/src/backend/impls/objectbox/models/src/recovery_region.dart create mode 100644 lib/src/regions/shapes/multi.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f2daab49..708b44aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,9 +35,11 @@ Additionally, vector tiles are now supported in theory, as the internal caching/ * Replaced `CacheBehavior` with `BrowseLoadingStrategy` * Replaced `FMTCBrowsingErrorHandler` with `BrowsingExceptionHandler`, which may now return bytes to be displayed instead of (re)throwing exception * Removed `FMTCTileProviderSettings` & absorbed properties directly into `FMTCTileProvider` -* Improved speed (by massive amounts) and accuracy & reduced memory consumption of `CircleRegion`'s tile generation & counting algorithm -* `RootExternal.export` now returns the number of exported tiles +* Improvements & additions to bulk downloadable `BaseRegion`s + * Added `MultiRegion`, which contains multiple other `BaseRegion`s + * Improved speed (by massive amounts) and accuracy & reduced memory consumption of `CircleRegion`'s tile generation & counting algorithm * Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `FMTCTileProvider.urlTransformerOmitKeyValues` utility method to provide old behaviour with more customizability) +* `RootExternal.export` now returns the number of exported tiles * Removed deprecated remnants from v9.* * Other generic improvements diff --git a/example/lib/src/screens/configure_download/components/region_information.dart b/example/lib/src/screens/configure_download/components/region_information.dart index de62ac63..06a75de6 100644 --- a/example/lib/src/screens/configure_download/components/region_information.dart +++ b/example/lib/src/screens/configure_download/components/region_information.dart @@ -170,6 +170,9 @@ class _RegionInformationState extends State { ), ]; }, + multi: (_) => throw UnsupportedError( + '`MultiRegion` is not supported in the example app', + ), ), ], ), diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 4e44b9c0..15dc19ca 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -56,6 +56,7 @@ part 'src/providers/tile_provider/tile_provider.dart'; part 'src/providers/tile_provider/typedefs.dart'; part 'src/regions/base_region.dart'; part 'src/regions/downloadable_region.dart'; +part 'src/regions/shapes/multi.dart'; part 'src/regions/recovered_region.dart'; part 'src/regions/shapes/circle.dart'; part 'src/regions/shapes/custom_polygon.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/backend.dart b/lib/src/backend/impls/objectbox/backend/backend.dart index c1450acc..93c244c8 100644 --- a/lib/src/backend/impls/objectbox/backend/backend.dart +++ b/lib/src/backend/impls/objectbox/backend/backend.dart @@ -19,6 +19,7 @@ import '../../../../misc/int_extremes.dart'; import '../../../export_internal.dart'; import '../models/generated/objectbox.g.dart'; import '../models/src/recovery.dart'; +import '../models/src/recovery_region.dart'; import '../models/src/root.dart'; import '../models/src/store.dart'; import '../models/src/tile.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index b2b137f9..02129227 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -772,14 +772,32 @@ Future _worker( case _CmdType.cancelRecovery: final id = cmd.args['id']! as int; - root - .box() - .query(ObjectBoxRecovery_.refId.equals(id)) - .build() - ..remove() - ..close(); + void recursiveDeleteRecoveryRegions(ObjectBoxRecoveryRegion region) { + if (region.typeId == 4) { + region.multiLinkedRegions.forEach(recursiveDeleteRecoveryRegions); + } + root.box().remove(region.id); + } - sendRes(id: cmd.id); + root.runInTransaction( + TxMode.write, + () { + final detailsQuery = root + .box() + .query(ObjectBoxRecovery_.refId.equals(id)) + .build(); + + recursiveDeleteRecoveryRegions( + detailsQuery.findUnique()!.region.target!, + ); + + detailsQuery.remove(); + + sendRes(id: cmd.id); + + detailsQuery.close(); + }, + ); case _CmdType.watchRecovery: final triggerImmediately = cmd.args['triggerImmediately']! as bool; @@ -880,7 +898,7 @@ Future _worker( .length .then( (numExportedStores) { - if (numExportedStores == 0) throw StateError('Unpossible.'); + if (numExportedStores == 0) throw StateError('Unpossible'); final exportingTiles = root.runInTransaction( TxMode.read, diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart index 7c5fb480..27355622 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart @@ -152,16 +152,38 @@ class _ObjectBoxBackendThreadSafeImpl implements FMTCBackendInternalThreadSafe { required String storeName, required DownloadableRegion region, required int endTile, - }) => - expectInitialisedRoot.box().put( + }) { + expectInitialisedRoot; + + ObjectBoxRecoveryRegion recursiveWriteRecoveryRegions(BaseRegion region) { + final recoveryRegion = ObjectBoxRecoveryRegion.fromRegion(region: region); + + if (region case final MultiRegion region) { + recoveryRegion.multiLinkedRegions + .addAll(region.regions.map(recursiveWriteRecoveryRegions)); + } + + _root! + .box() + .put(recoveryRegion, mode: PutMode.insert); + + return recoveryRegion; + } + + _root!.runInTransaction( + TxMode.write, + () => _root!.box().put( ObjectBoxRecovery.fromRegion( refId: id, storeName: storeName, region: region, endTile: endTile, + target: recursiveWriteRecoveryRegions(region.originalRegion), ), mode: PutMode.insert, - ); + ), + ); + } @override void updateRecovery({ diff --git a/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json b/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json index c1bf3d7a..a7397a4e 100644 --- a/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json +++ b/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json @@ -5,7 +5,7 @@ "entities": [ { "id": "1:5472631385587455945", - "lastPropertyId": "21:3590067577930145922", + "lastPropertyId": "22:2247444187089993412", "name": "ObjectBoxRecovery", "properties": [ { @@ -52,69 +52,12 @@ "type": 6 }, { - "id": "9:7217406424708558740", - "name": "typeId", - "type": 6 - }, - { - "id": "10:5971465387225017460", - "name": "rectNwLat", - "type": 8 - }, - { - "id": "11:6703340231106164623", - "name": "rectNwLng", - "type": 8 - }, - { - "id": "12:741105584939284321", - "name": "rectSeLat", - "type": 8 - }, - { - "id": "13:2939837278126242427", - "name": "rectSeLng", - "type": 8 - }, - { - "id": "14:2393337671661697697", - "name": "circleCenterLat", - "type": 8 - }, - { - "id": "15:8055510540122966413", - "name": "circleCenterLng", - "type": 8 - }, - { - "id": "16:9110709438555760246", - "name": "circleRadius", - "type": 8 - }, - { - "id": "17:8363656194353400366", - "name": "lineLats", - "type": 29 - }, - { - "id": "18:7008680868853575786", - "name": "lineLngs", - "type": 29 - }, - { - "id": "19:7670007285707179405", - "name": "lineRadius", - "type": 8 - }, - { - "id": "20:490933261424375687", - "name": "customPolygonLats", - "type": 29 - }, - { - "id": "21:3590067577930145922", - "name": "customPolygonLngs", - "type": 29 + "id": "22:2247444187089993412", + "name": "regionId", + "type": 11, + "flags": 520, + "indexId": "5:2172676985778936605", + "relationTarget": "ObjectBoxRecoveryRegion" } ], "relations": [] @@ -232,17 +175,116 @@ } ], "relations": [] + }, + { + "id": "5:5692106664767803360", + "lastPropertyId": "14:2380085283533950474", + "name": "ObjectBoxRecoveryRegion", + "properties": [ + { + "id": "1:4629353002259573678", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:1116094237557270575", + "name": "typeId", + "type": 6 + }, + { + "id": "3:8476920990388836149", + "name": "rectNwLat", + "type": 8 + }, + { + "id": "4:3015129163086269263", + "name": "rectNwLng", + "type": 8 + }, + { + "id": "5:8302525711584098439", + "name": "rectSeLat", + "type": 8 + }, + { + "id": "6:1939082009138163489", + "name": "rectSeLng", + "type": 8 + }, + { + "id": "7:5260761364748928203", + "name": "circleCenterLat", + "type": 8 + }, + { + "id": "8:3329863004721648966", + "name": "circleCenterLng", + "type": 8 + }, + { + "id": "9:8471244801699851283", + "name": "circleRadius", + "type": 8 + }, + { + "id": "10:5745879403192313286", + "name": "lineLats", + "type": 29 + }, + { + "id": "11:4679809662196927204", + "name": "lineLngs", + "type": 29 + }, + { + "id": "12:8730805542251345960", + "name": "lineRadius", + "type": 8 + }, + { + "id": "13:1607230668161719129", + "name": "customPolygonLats", + "type": 29 + }, + { + "id": "14:2380085283533950474", + "name": "customPolygonLngs", + "type": 29 + } + ], + "relations": [ + { + "id": "2:6378075033578405480", + "name": "multiLinkedRegions", + "targetId": "5:5692106664767803360" + } + ] } ], - "lastEntityId": "4:8718814737097934474", - "lastIndexId": "4:4857742396480146668", - "lastRelationId": "1:7496298295217061586", + "lastEntityId": "5:5692106664767803360", + "lastIndexId": "5:2172676985778936605", + "lastRelationId": "2:6378075033578405480", "lastSequenceId": "0:0", "modelVersion": 5, "modelVersionParserMinimum": 5, "retiredEntityUids": [], "retiredIndexUids": [], - "retiredPropertyUids": [], + "retiredPropertyUids": [ + 7217406424708558740, + 5971465387225017460, + 6703340231106164623, + 741105584939284321, + 2939837278126242427, + 2393337671661697697, + 8055510540122966413, + 9110709438555760246, + 8363656194353400366, + 7008680868853575786, + 7670007285707179405, + 490933261424375687, + 3590067577930145922 + ], "retiredRelationUids": [], "version": 1 } \ No newline at end of file diff --git a/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart b/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart index dcbf5eb6..9966f6a1 100644 --- a/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart +++ b/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart @@ -15,6 +15,7 @@ import 'package:objectbox/objectbox.dart' as obx; import 'package:objectbox_flutter_libs/objectbox_flutter_libs.dart'; import '../../../../../../src/backend/impls/objectbox/models/src/recovery.dart'; +import '../../../../../../src/backend/impls/objectbox/models/src/recovery_region.dart'; import '../../../../../../src/backend/impls/objectbox/models/src/root.dart'; import '../../../../../../src/backend/impls/objectbox/models/src/store.dart'; import '../../../../../../src/backend/impls/objectbox/models/src/tile.dart'; @@ -25,7 +26,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(1, 5472631385587455945), name: 'ObjectBoxRecovery', - lastPropertyId: const obx_int.IdUid(21, 3590067577930145922), + lastPropertyId: const obx_int.IdUid(22, 2247444187089993412), flags: 0, properties: [ obx_int.ModelProperty( @@ -70,70 +71,12 @@ final _entities = [ type: 6, flags: 0), obx_int.ModelProperty( - id: const obx_int.IdUid(9, 7217406424708558740), - name: 'typeId', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(10, 5971465387225017460), - name: 'rectNwLat', - type: 8, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(11, 6703340231106164623), - name: 'rectNwLng', - type: 8, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(12, 741105584939284321), - name: 'rectSeLat', - type: 8, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(13, 2939837278126242427), - name: 'rectSeLng', - type: 8, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(14, 2393337671661697697), - name: 'circleCenterLat', - type: 8, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(15, 8055510540122966413), - name: 'circleCenterLng', - type: 8, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(16, 9110709438555760246), - name: 'circleRadius', - type: 8, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(17, 8363656194353400366), - name: 'lineLats', - type: 29, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(18, 7008680868853575786), - name: 'lineLngs', - type: 29, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(19, 7670007285707179405), - name: 'lineRadius', - type: 8, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(20, 490933261424375687), - name: 'customPolygonLats', - type: 29, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(21, 3590067577930145922), - name: 'customPolygonLngs', - type: 29, - flags: 0) + id: const obx_int.IdUid(22, 2247444187089993412), + name: 'regionId', + type: 11, + flags: 520, + indexId: const obx_int.IdUid(5, 2172676985778936605), + relationTarget: 'ObjectBoxRecoveryRegion') ], relations: [], backlinks: []), @@ -249,6 +192,90 @@ final _entities = [ flags: 0) ], relations: [], + backlinks: []), + obx_int.ModelEntity( + id: const obx_int.IdUid(5, 5692106664767803360), + name: 'ObjectBoxRecoveryRegion', + lastPropertyId: const obx_int.IdUid(14, 2380085283533950474), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 4629353002259573678), + name: 'id', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 1116094237557270575), + name: 'typeId', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 8476920990388836149), + name: 'rectNwLat', + type: 8, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 3015129163086269263), + name: 'rectNwLng', + type: 8, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 8302525711584098439), + name: 'rectSeLat', + type: 8, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 1939082009138163489), + name: 'rectSeLng', + type: 8, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 5260761364748928203), + name: 'circleCenterLat', + type: 8, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 3329863004721648966), + name: 'circleCenterLng', + type: 8, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 8471244801699851283), + name: 'circleRadius', + type: 8, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 5745879403192313286), + name: 'lineLats', + type: 29, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 4679809662196927204), + name: 'lineLngs', + type: 29, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(12, 8730805542251345960), + name: 'lineRadius', + type: 8, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(13, 1607230668161719129), + name: 'customPolygonLats', + type: 29, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(14, 2380085283533950474), + name: 'customPolygonLngs', + type: 29, + flags: 0) + ], + relations: [ + obx_int.ModelRelation( + id: const obx_int.IdUid(2, 6378075033578405480), + name: 'multiLinkedRegions', + targetId: const obx_int.IdUid(5, 5692106664767803360)) + ], backlinks: []) ]; @@ -287,13 +314,27 @@ Future openStore( obx_int.ModelDefinition getObjectBoxModel() { final model = obx_int.ModelInfo( entities: _entities, - lastEntityId: const obx_int.IdUid(4, 8718814737097934474), - lastIndexId: const obx_int.IdUid(4, 4857742396480146668), - lastRelationId: const obx_int.IdUid(1, 7496298295217061586), + lastEntityId: const obx_int.IdUid(5, 5692106664767803360), + lastIndexId: const obx_int.IdUid(5, 2172676985778936605), + lastRelationId: const obx_int.IdUid(2, 6378075033578405480), lastSequenceId: const obx_int.IdUid(0, 0), retiredEntityUids: const [], retiredIndexUids: const [], - retiredPropertyUids: const [], + retiredPropertyUids: const [ + 7217406424708558740, + 5971465387225017460, + 6703340231106164623, + 741105584939284321, + 2939837278126242427, + 2393337671661697697, + 8055510540122966413, + 9110709438555760246, + 8363656194353400366, + 7008680868853575786, + 7670007285707179405, + 490933261424375687, + 3590067577930145922 + ], retiredRelationUids: const [], modelVersion: 5, modelVersionParserMinimum: 5, @@ -302,7 +343,7 @@ obx_int.ModelDefinition getObjectBoxModel() { final bindings = { ObjectBoxRecovery: obx_int.EntityDefinition( model: _entities[0], - toOneRelations: (ObjectBoxRecovery object) => [], + toOneRelations: (ObjectBoxRecovery object) => [object.region], toManyRelations: (ObjectBoxRecovery object) => {}, getId: (ObjectBoxRecovery object) => object.id, setId: (ObjectBoxRecovery object, int id) { @@ -310,19 +351,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, objectToFB: (ObjectBoxRecovery object, fb.Builder fbb) { final storeNameOffset = fbb.writeString(object.storeName); - final lineLatsOffset = object.lineLats == null - ? null - : fbb.writeListFloat64(object.lineLats!); - final lineLngsOffset = object.lineLngs == null - ? null - : fbb.writeListFloat64(object.lineLngs!); - final customPolygonLatsOffset = object.customPolygonLats == null - ? null - : fbb.writeListFloat64(object.customPolygonLats!); - final customPolygonLngsOffset = object.customPolygonLngs == null - ? null - : fbb.writeListFloat64(object.customPolygonLngs!); - fbb.startTable(22); + fbb.startTable(23); fbb.addInt64(0, object.id); fbb.addInt64(1, object.refId); fbb.addOffset(2, storeNameOffset); @@ -331,19 +360,7 @@ obx_int.ModelDefinition getObjectBoxModel() { fbb.addInt64(5, object.maxZoom); fbb.addInt64(6, object.startTile); fbb.addInt64(7, object.endTile); - fbb.addInt64(8, object.typeId); - fbb.addFloat64(9, object.rectNwLat); - fbb.addFloat64(10, object.rectNwLng); - fbb.addFloat64(11, object.rectSeLat); - fbb.addFloat64(12, object.rectSeLng); - fbb.addFloat64(13, object.circleCenterLat); - fbb.addFloat64(14, object.circleCenterLng); - fbb.addFloat64(15, object.circleRadius); - fbb.addOffset(16, lineLatsOffset); - fbb.addOffset(17, lineLngsOffset); - fbb.addFloat64(18, object.lineRadius); - fbb.addOffset(19, customPolygonLatsOffset); - fbb.addOffset(20, customPolygonLngsOffset); + fbb.addInt64(21, object.region.targetId); fbb.finish(fbb.endTable()); return object.id; }, @@ -356,8 +373,6 @@ obx_int.ModelDefinition getObjectBoxModel() { .vTableGet(buffer, rootOffset, 8, ''); final creationTimeParam = DateTime.fromMillisecondsSinceEpoch( const fb.Int64Reader().vTableGet(buffer, rootOffset, 10, 0)); - final typeIdParam = - const fb.Int64Reader().vTableGet(buffer, rootOffset, 20, 0); final minZoomParam = const fb.Int64Reader().vTableGet(buffer, rootOffset, 12, 0); final maxZoomParam = @@ -366,57 +381,20 @@ obx_int.ModelDefinition getObjectBoxModel() { const fb.Int64Reader().vTableGet(buffer, rootOffset, 16, 0); final endTileParam = const fb.Int64Reader().vTableGet(buffer, rootOffset, 18, 0); - final rectNwLatParam = const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 22); - final rectNwLngParam = const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 24); - final rectSeLatParam = const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 26); - final rectSeLngParam = const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 28); - final circleCenterLatParam = const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 30); - final circleCenterLngParam = const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 32); - final circleRadiusParam = const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 34); - final lineLatsParam = - const fb.ListReader(fb.Float64Reader(), lazy: false) - .vTableGetNullable(buffer, rootOffset, 36); - final lineLngsParam = - const fb.ListReader(fb.Float64Reader(), lazy: false) - .vTableGetNullable(buffer, rootOffset, 38); - final lineRadiusParam = const fb.Float64Reader() - .vTableGetNullable(buffer, rootOffset, 40); - final customPolygonLatsParam = - const fb.ListReader(fb.Float64Reader(), lazy: false) - .vTableGetNullable(buffer, rootOffset, 42); - final customPolygonLngsParam = - const fb.ListReader(fb.Float64Reader(), lazy: false) - .vTableGetNullable(buffer, rootOffset, 44); + final regionParam = obx.ToOne( + targetId: + const fb.Int64Reader().vTableGet(buffer, rootOffset, 46, 0)); final object = ObjectBoxRecovery( refId: refIdParam, storeName: storeNameParam, creationTime: creationTimeParam, - typeId: typeIdParam, minZoom: minZoomParam, maxZoom: maxZoomParam, startTile: startTileParam, endTile: endTileParam, - rectNwLat: rectNwLatParam, - rectNwLng: rectNwLngParam, - rectSeLat: rectSeLatParam, - rectSeLng: rectSeLngParam, - circleCenterLat: circleCenterLatParam, - circleCenterLng: circleCenterLngParam, - circleRadius: circleRadiusParam, - lineLats: lineLatsParam, - lineLngs: lineLngsParam, - lineRadius: lineRadiusParam, - customPolygonLats: customPolygonLatsParam, - customPolygonLngs: customPolygonLngsParam) + region: regionParam) ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); - + object.region.attach(store); return object; }), ObjectBoxStore: obx_int.EntityDefinition( @@ -542,6 +520,104 @@ obx_int.ModelDefinition getObjectBoxModel() { ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); return object; + }), + ObjectBoxRecoveryRegion: obx_int.EntityDefinition( + model: _entities[4], + toOneRelations: (ObjectBoxRecoveryRegion object) => [], + toManyRelations: (ObjectBoxRecoveryRegion object) => { + obx_int.RelInfo.toMany(2, object.id): + object.multiLinkedRegions + }, + getId: (ObjectBoxRecoveryRegion object) => object.id, + setId: (ObjectBoxRecoveryRegion object, int id) { + object.id = id; + }, + objectToFB: (ObjectBoxRecoveryRegion object, fb.Builder fbb) { + final lineLatsOffset = object.lineLats == null + ? null + : fbb.writeListFloat64(object.lineLats!); + final lineLngsOffset = object.lineLngs == null + ? null + : fbb.writeListFloat64(object.lineLngs!); + final customPolygonLatsOffset = object.customPolygonLats == null + ? null + : fbb.writeListFloat64(object.customPolygonLats!); + final customPolygonLngsOffset = object.customPolygonLngs == null + ? null + : fbb.writeListFloat64(object.customPolygonLngs!); + fbb.startTable(15); + fbb.addInt64(0, object.id); + fbb.addInt64(1, object.typeId); + fbb.addFloat64(2, object.rectNwLat); + fbb.addFloat64(3, object.rectNwLng); + fbb.addFloat64(4, object.rectSeLat); + fbb.addFloat64(5, object.rectSeLng); + fbb.addFloat64(6, object.circleCenterLat); + fbb.addFloat64(7, object.circleCenterLng); + fbb.addFloat64(8, object.circleRadius); + fbb.addOffset(9, lineLatsOffset); + fbb.addOffset(10, lineLngsOffset); + fbb.addFloat64(11, object.lineRadius); + fbb.addOffset(12, customPolygonLatsOffset); + fbb.addOffset(13, customPolygonLngsOffset); + fbb.finish(fbb.endTable()); + return object.id; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final typeIdParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 6, 0); + final rectNwLatParam = + const fb.Float64Reader().vTableGetNullable(buffer, rootOffset, 8); + final rectNwLngParam = const fb.Float64Reader() + .vTableGetNullable(buffer, rootOffset, 10); + final rectSeLatParam = const fb.Float64Reader() + .vTableGetNullable(buffer, rootOffset, 12); + final rectSeLngParam = const fb.Float64Reader() + .vTableGetNullable(buffer, rootOffset, 14); + final circleCenterLatParam = const fb.Float64Reader() + .vTableGetNullable(buffer, rootOffset, 16); + final circleCenterLngParam = const fb.Float64Reader() + .vTableGetNullable(buffer, rootOffset, 18); + final circleRadiusParam = const fb.Float64Reader() + .vTableGetNullable(buffer, rootOffset, 20); + final lineLatsParam = + const fb.ListReader(fb.Float64Reader(), lazy: false) + .vTableGetNullable(buffer, rootOffset, 22); + final lineLngsParam = + const fb.ListReader(fb.Float64Reader(), lazy: false) + .vTableGetNullable(buffer, rootOffset, 24); + final lineRadiusParam = const fb.Float64Reader() + .vTableGetNullable(buffer, rootOffset, 26); + final customPolygonLatsParam = + const fb.ListReader(fb.Float64Reader(), lazy: false) + .vTableGetNullable(buffer, rootOffset, 28); + final customPolygonLngsParam = + const fb.ListReader(fb.Float64Reader(), lazy: false) + .vTableGetNullable(buffer, rootOffset, 30); + final multiLinkedRegionsParam = obx.ToMany(); + final object = ObjectBoxRecoveryRegion( + typeId: typeIdParam, + rectNwLat: rectNwLatParam, + rectNwLng: rectNwLngParam, + rectSeLat: rectSeLatParam, + rectSeLng: rectSeLngParam, + circleCenterLat: circleCenterLatParam, + circleCenterLng: circleCenterLngParam, + circleRadius: circleRadiusParam, + lineLats: lineLatsParam, + lineLngs: lineLngsParam, + lineRadius: lineRadiusParam, + customPolygonLats: customPolygonLatsParam, + customPolygonLngs: customPolygonLngsParam, + multiLinkedRegions: multiLinkedRegionsParam) + ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + obx_int.InternalToManyAccess.setRelInfo( + object.multiLinkedRegions, + store, + obx_int.RelInfo.toMany(2, object.id)); + return object; }) }; @@ -582,59 +658,10 @@ class ObjectBoxRecovery_ { static final endTile = obx.QueryIntegerProperty(_entities[0].properties[7]); - /// See [ObjectBoxRecovery.typeId]. - static final typeId = - obx.QueryIntegerProperty(_entities[0].properties[8]); - - /// See [ObjectBoxRecovery.rectNwLat]. - static final rectNwLat = - obx.QueryDoubleProperty(_entities[0].properties[9]); - - /// See [ObjectBoxRecovery.rectNwLng]. - static final rectNwLng = - obx.QueryDoubleProperty(_entities[0].properties[10]); - - /// See [ObjectBoxRecovery.rectSeLat]. - static final rectSeLat = - obx.QueryDoubleProperty(_entities[0].properties[11]); - - /// See [ObjectBoxRecovery.rectSeLng]. - static final rectSeLng = - obx.QueryDoubleProperty(_entities[0].properties[12]); - - /// See [ObjectBoxRecovery.circleCenterLat]. - static final circleCenterLat = - obx.QueryDoubleProperty(_entities[0].properties[13]); - - /// See [ObjectBoxRecovery.circleCenterLng]. - static final circleCenterLng = - obx.QueryDoubleProperty(_entities[0].properties[14]); - - /// See [ObjectBoxRecovery.circleRadius]. - static final circleRadius = - obx.QueryDoubleProperty(_entities[0].properties[15]); - - /// See [ObjectBoxRecovery.lineLats]. - static final lineLats = obx.QueryDoubleVectorProperty( - _entities[0].properties[16]); - - /// See [ObjectBoxRecovery.lineLngs]. - static final lineLngs = obx.QueryDoubleVectorProperty( - _entities[0].properties[17]); - - /// See [ObjectBoxRecovery.lineRadius]. - static final lineRadius = - obx.QueryDoubleProperty(_entities[0].properties[18]); - - /// See [ObjectBoxRecovery.customPolygonLats]. - static final customPolygonLats = - obx.QueryDoubleVectorProperty( - _entities[0].properties[19]); - - /// See [ObjectBoxRecovery.customPolygonLngs]. - static final customPolygonLngs = - obx.QueryDoubleVectorProperty( - _entities[0].properties[20]); + /// See [ObjectBoxRecovery.region]. + static final region = + obx.QueryRelationToOne( + _entities[0].properties[8]); } /// [ObjectBoxStore] entity fields to define ObjectBox queries. @@ -709,3 +736,73 @@ class ObjectBoxRoot_ { static final size = obx.QueryIntegerProperty(_entities[3].properties[2]); } + +/// [ObjectBoxRecoveryRegion] entity fields to define ObjectBox queries. +class ObjectBoxRecoveryRegion_ { + /// See [ObjectBoxRecoveryRegion.id]. + static final id = obx.QueryIntegerProperty( + _entities[4].properties[0]); + + /// See [ObjectBoxRecoveryRegion.typeId]. + static final typeId = obx.QueryIntegerProperty( + _entities[4].properties[1]); + + /// See [ObjectBoxRecoveryRegion.rectNwLat]. + static final rectNwLat = obx.QueryDoubleProperty( + _entities[4].properties[2]); + + /// See [ObjectBoxRecoveryRegion.rectNwLng]. + static final rectNwLng = obx.QueryDoubleProperty( + _entities[4].properties[3]); + + /// See [ObjectBoxRecoveryRegion.rectSeLat]. + static final rectSeLat = obx.QueryDoubleProperty( + _entities[4].properties[4]); + + /// See [ObjectBoxRecoveryRegion.rectSeLng]. + static final rectSeLng = obx.QueryDoubleProperty( + _entities[4].properties[5]); + + /// See [ObjectBoxRecoveryRegion.circleCenterLat]. + static final circleCenterLat = + obx.QueryDoubleProperty( + _entities[4].properties[6]); + + /// See [ObjectBoxRecoveryRegion.circleCenterLng]. + static final circleCenterLng = + obx.QueryDoubleProperty( + _entities[4].properties[7]); + + /// See [ObjectBoxRecoveryRegion.circleRadius]. + static final circleRadius = obx.QueryDoubleProperty( + _entities[4].properties[8]); + + /// See [ObjectBoxRecoveryRegion.lineLats]. + static final lineLats = + obx.QueryDoubleVectorProperty( + _entities[4].properties[9]); + + /// See [ObjectBoxRecoveryRegion.lineLngs]. + static final lineLngs = + obx.QueryDoubleVectorProperty( + _entities[4].properties[10]); + + /// See [ObjectBoxRecoveryRegion.lineRadius]. + static final lineRadius = obx.QueryDoubleProperty( + _entities[4].properties[11]); + + /// See [ObjectBoxRecoveryRegion.customPolygonLats]. + static final customPolygonLats = + obx.QueryDoubleVectorProperty( + _entities[4].properties[12]); + + /// See [ObjectBoxRecoveryRegion.customPolygonLngs]. + static final customPolygonLngs = + obx.QueryDoubleVectorProperty( + _entities[4].properties[13]); + + /// see [ObjectBoxRecoveryRegion.multiLinkedRegions] + static final multiLinkedRegions = + obx.QueryRelationToMany( + _entities[4].relations[0]); +} diff --git a/lib/src/backend/impls/objectbox/models/src/recovery.dart b/lib/src/backend/impls/objectbox/models/src/recovery.dart index 603bc5a1..83c8486a 100644 --- a/lib/src/backend/impls/objectbox/models/src/recovery.dart +++ b/lib/src/backend/impls/objectbox/models/src/recovery.dart @@ -1,119 +1,42 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; import 'package:objectbox/objectbox.dart'; import '../../../../../../flutter_map_tile_caching.dart'; +import 'recovery_region.dart'; -/// Represents a [RecoveredRegion] in ObjectBox +/// Represents a [RecoveredRegion] @Entity() -base class ObjectBoxRecovery { - /// Create a raw representation of a [RecoveredRegion] in ObjectBox - /// - /// Prefer using [ObjectBoxRecovery.fromRegion]. +class ObjectBoxRecovery { + /// Creates a representation of a [RecoveredRegion] ObjectBoxRecovery({ required this.refId, required this.storeName, required this.creationTime, - required this.typeId, required this.minZoom, required this.maxZoom, required this.startTile, required this.endTile, - required this.rectNwLat, - required this.rectNwLng, - required this.rectSeLat, - required this.rectSeLng, - required this.circleCenterLat, - required this.circleCenterLng, - required this.circleRadius, - required this.lineLats, - required this.lineLngs, - required this.lineRadius, - required this.customPolygonLats, - required this.customPolygonLngs, + required this.region, }); - /// Create a raw representation of a [RecoveredRegion] in ObjectBox from a - /// [DownloadableRegion] + /// Creates a representation of a [RecoveredRegion] + /// + /// [target] should refer to the [BaseRegion] representation + /// [ObjectBoxRecoveryRegion]. ObjectBoxRecovery.fromRegion({ required this.refId, required this.storeName, - required DownloadableRegion region, required this.endTile, + required DownloadableRegion region, + required ObjectBoxRecoveryRegion target, }) : creationTime = DateTime.timestamp(), - typeId = region.when( - rectangle: (_) => 0, - circle: (_) => 1, - line: (_) => 2, - customPolygon: (_) => 3, - ), minZoom = region.minZoom, maxZoom = region.maxZoom, startTile = region.start, - rectNwLat = region.originalRegion is RectangleRegion - ? (region.originalRegion as RectangleRegion) - .bounds - .northWest - .latitude - : null, - rectNwLng = region.originalRegion is RectangleRegion - ? (region.originalRegion as RectangleRegion) - .bounds - .northWest - .longitude - : null, - rectSeLat = region.originalRegion is RectangleRegion - ? (region.originalRegion as RectangleRegion) - .bounds - .southEast - .latitude - : null, - rectSeLng = region.originalRegion is RectangleRegion - ? (region.originalRegion as RectangleRegion) - .bounds - .southEast - .longitude - : null, - circleCenterLat = region.originalRegion is CircleRegion - ? (region.originalRegion as CircleRegion).center.latitude - : null, - circleCenterLng = region.originalRegion is CircleRegion - ? (region.originalRegion as CircleRegion).center.longitude - : null, - circleRadius = region.originalRegion is CircleRegion - ? (region.originalRegion as CircleRegion).radius - : null, - lineLats = region.originalRegion is LineRegion - ? (region.originalRegion as LineRegion) - .line - .map((c) => c.latitude) - .toList(growable: false) - : null, - lineLngs = region.originalRegion is LineRegion - ? (region.originalRegion as LineRegion) - .line - .map((c) => c.longitude) - .toList(growable: false) - : null, - lineRadius = region.originalRegion is LineRegion - ? (region.originalRegion as LineRegion).radius - : null, - customPolygonLats = region.originalRegion is CustomPolygonRegion - ? (region.originalRegion as CustomPolygonRegion) - .outline - .map((c) => c.latitude) - .toList(growable: false) - : null, - customPolygonLngs = region.originalRegion is CustomPolygonRegion - ? (region.originalRegion as CustomPolygonRegion) - .outline - .map((c) => c.longitude) - .toList(growable: false) - : null; + region = ToOne(target: target); /// ObjectBox ID /// @@ -125,103 +48,43 @@ base class ObjectBoxRecovery { /// Corresponds to [RecoveredRegion.id] @Index() @Unique() - int refId; + final int refId; /// Corresponds to [RecoveredRegion.storeName] - String storeName; + final String storeName; /// The timestamp of when this object was created/stored @Property(type: PropertyType.date) - DateTime creationTime; + final DateTime creationTime; /// Corresponds to [RecoveredRegion.minZoom] & [DownloadableRegion.minZoom] - int minZoom; + final int minZoom; /// Corresponds to [RecoveredRegion.maxZoom] & [DownloadableRegion.maxZoom] - int maxZoom; + final int maxZoom; /// Corresponds to [RecoveredRegion.start] & [DownloadableRegion.start] + /// + /// Is not immutable because it is updated during downloads. int startTile; /// Corresponds to [RecoveredRegion.end] & [DownloadableRegion.end] - int endTile; - - /// Corresponds to the generic type of [DownloadableRegion] - /// - /// Values must be as follows: - /// * 0: rect - /// * 1: circle - /// * 2: line - /// * 3: custom polygon - int typeId; - - /// Corresponds to [RecoveredRegion.bounds] ([RectangleRegion.bounds]) - double? rectNwLat; - - /// Corresponds to [RecoveredRegion.bounds] ([RectangleRegion.bounds]) - double? rectNwLng; - - /// Corresponds to [RecoveredRegion.bounds] ([RectangleRegion.bounds]) - double? rectSeLat; - - /// Corresponds to [RecoveredRegion.bounds] ([RectangleRegion.bounds]) - double? rectSeLng; - - /// Corresponds to [RecoveredRegion.center] ([CircleRegion.center]) - double? circleCenterLat; - - /// Corresponds to [RecoveredRegion.center] ([CircleRegion.center]) - double? circleCenterLng; - - /// Corresponds to [RecoveredRegion.radius] ([CircleRegion.radius]) - double? circleRadius; - - /// Corresponds to [RecoveredRegion.line] ([LineRegion.line]) - List? lineLats; - - /// Corresponds to [RecoveredRegion.line] ([LineRegion.line]) - List? lineLngs; - - /// Corresponds to [RecoveredRegion.radius] ([LineRegion.radius]) - double? lineRadius; - - /// Corresponds to [RecoveredRegion.line] ([CustomPolygonRegion.outline]) - List? customPolygonLats; + final int endTile; - /// Corresponds to [RecoveredRegion.line] ([CustomPolygonRegion.outline]) - List? customPolygonLngs; + /// Recoverable [MultiRegion]s are implemented in recovery as a single 'root' + /// [ObjectBoxRecovery] with only this property defined, and linked + /// [ObjectBoxRecovery]s for each sub-region + final ToOne region; /// Convert this object into a [RecoveredRegion] RecoveredRegion toRegion() => RecoveredRegion( id: refId, storeName: storeName, time: creationTime, - bounds: typeId == 0 - ? LatLngBounds( - LatLng(rectNwLat!, rectNwLng!), - LatLng(rectSeLat!, rectSeLng!), - ) - : null, - center: typeId == 1 ? LatLng(circleCenterLat!, circleCenterLng!) : null, - line: typeId == 2 - ? List.generate( - lineLats!.length, - (i) => LatLng(lineLats![i], lineLngs![i]), - ) - : typeId == 3 - ? List.generate( - customPolygonLats!.length, - (i) => LatLng(customPolygonLats![i], customPolygonLngs![i]), - ) - : null, - radius: typeId == 1 - ? circleRadius! - : typeId == 2 - ? lineRadius! - : null, minZoom: minZoom, maxZoom: maxZoom, start: startTile, end: endTile, + region: region.target!.toRegion(), ); } diff --git a/lib/src/backend/impls/objectbox/models/src/recovery_region.dart b/lib/src/backend/impls/objectbox/models/src/recovery_region.dart new file mode 100644 index 00000000..48b232ee --- /dev/null +++ b/lib/src/backend/impls/objectbox/models/src/recovery_region.dart @@ -0,0 +1,163 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meta/meta.dart'; +import 'package:objectbox/objectbox.dart'; + +import '../../../../../../flutter_map_tile_caching.dart'; + +/// Serialised [BaseRegion] +@Entity() +class ObjectBoxRecoveryRegion { + /// Create a searialised [BaseRegion] + ObjectBoxRecoveryRegion({ + required this.typeId, + required this.rectNwLat, + required this.rectNwLng, + required this.rectSeLat, + required this.rectSeLng, + required this.circleCenterLat, + required this.circleCenterLng, + required this.circleRadius, + required this.lineLats, + required this.lineLngs, + required this.lineRadius, + required this.customPolygonLats, + required this.customPolygonLngs, + required this.multiLinkedRegions, + }); + + /// Create a searialised [BaseRegion] + /// + /// If representing a [MultiRegion], then [multiLinkedRegions] must be filled + /// manually. + ObjectBoxRecoveryRegion.fromRegion({required BaseRegion region}) + : typeId = region.when( + rectangle: (_) => 0, + circle: (_) => 1, + line: (_) => 2, + customPolygon: (_) => 3, + multi: (_) => 4, + ), + rectNwLat = + region is RectangleRegion ? region.bounds.northWest.latitude : null, + rectNwLng = region is RectangleRegion + ? region.bounds.northWest.longitude + : null, + rectSeLat = + region is RectangleRegion ? region.bounds.southEast.latitude : null, + rectSeLng = region is RectangleRegion + ? region.bounds.southEast.longitude + : null, + circleCenterLat = + region is CircleRegion ? region.center.latitude : null, + circleCenterLng = + region is CircleRegion ? region.center.longitude : null, + circleRadius = region is CircleRegion ? region.radius : null, + lineLats = region is LineRegion + ? region.line.map((c) => c.latitude).toList(growable: false) + : null, + lineLngs = region is LineRegion + ? region.line.map((c) => c.longitude).toList(growable: false) + : null, + lineRadius = region is LineRegion ? region.radius : null, + customPolygonLats = region is CustomPolygonRegion + ? region.outline.map((c) => c.latitude).toList(growable: false) + : null, + customPolygonLngs = region is CustomPolygonRegion + ? region.outline.map((c) => c.longitude).toList(growable: false) + : null, + multiLinkedRegions = ToMany(); + + /// ObjectBox ID + @Id() + @internal + int id = 0; + + /// Corresponds to the generic type of [DownloadableRegion] + /// + /// Values must be as follows: + /// * 0: rect + /// * 1: circle + /// * 2: line + /// * 3: custom polygon + /// * 4: multi + final int typeId; + + /// Corresponds to [RectangleRegion.bounds] + final double? rectNwLat; + + /// Corresponds to [RectangleRegion.bounds] + final double? rectNwLng; + + /// Corresponds to [RectangleRegion.bounds] + final double? rectSeLat; + + /// Corresponds to [RectangleRegion.bounds] + final double? rectSeLng; + + /// Corresponds to [CircleRegion.center] + final double? circleCenterLat; + + /// Corresponds to [CircleRegion.center] + final double? circleCenterLng; + + /// Corresponds to [CircleRegion.radius] + final double? circleRadius; + + /// Corresponds to [LineRegion.line] + final List? lineLats; + + /// Corresponds to [LineRegion.line] + final List? lineLngs; + + /// Corresponds to [LineRegion.radius] + final double? lineRadius; + + /// Corresponds to [CustomPolygonRegion.outline] + final List? customPolygonLats; + + /// Corresponds to [CustomPolygonRegion.outline] + final List? customPolygonLngs; + + /// Corresponds to [MultiRegion.regions] + final ToMany multiLinkedRegions; + + /// Convert to a [BaseRegion] + /// + /// Will read from [multiLinkedRegions] if is a [MultiRegion]. + BaseRegion toRegion() => switch (typeId) { + 0 => RectangleRegion( + LatLngBounds( + LatLng(rectNwLat!, rectNwLng!), + LatLng(rectSeLat!, rectSeLng!), + ), + ), + 1 => CircleRegion( + LatLng(circleCenterLat!, circleCenterLng!), + circleRadius!, + ), + 2 => LineRegion( + List.generate( + lineLats!.length, + (i) => LatLng(lineLats![i], lineLngs![i]), + ), + lineRadius!, + ), + 3 => CustomPolygonRegion( + List.generate( + customPolygonLats!.length, + (i) => LatLng( + customPolygonLats![i], + customPolygonLngs![i], + ), + ), + ), + 4 => MultiRegion( + multiLinkedRegions.map((r) => r.toRegion()).toList(growable: false), + ), + _ => throw UnimplementedError('Unpossible'), + }; +} diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index fcadc521..88774d07 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -42,6 +42,7 @@ Future _downloadManager( circle: TileCounters.circleTiles, line: TileCounters.lineTiles, customPolygon: TileCounters.customPolygonTiles, + multi: TileCounters.multiTiles, ); // Setup sea tile removal system @@ -66,24 +67,37 @@ Future _downloadManager( late final List threadBuffersSize; late final List threadBuffersTiles; if (input.maxBufferLength != 0) { - // TODO: Verify `filled` threadBuffersSize = List.filled(input.parallelThreads, 0); threadBuffersTiles = List.filled(input.parallelThreads, 0); } // Setup tile generator isolate final tileReceivePort = ReceivePort(); + final tileIsolate = await Isolate.spawn( - input.region.when( - rectangle: (_) => TileGenerators.rectangleTiles, - circle: (_) => TileGenerators.circleTiles, - line: (_) => TileGenerators.lineTiles, - customPolygon: (_) => TileGenerators.customPolygonTiles, + (({SendPort sendPort, DownloadableRegion region}) input) => + input.region.when( + rectangle: (region) => TileGenerators.rectangleTiles( + (sendPort: input.sendPort, region: region), + ), + circle: (region) => TileGenerators.circleTiles( + (sendPort: input.sendPort, region: region), + ), + line: (region) => TileGenerators.lineTiles( + (sendPort: input.sendPort, region: region), + ), + customPolygon: (region) => TileGenerators.customPolygonTiles( + (sendPort: input.sendPort, region: region), + ), + multi: (region) => TileGenerators.multiTiles( + (sendPort: input.sendPort, region: region), + ), ), (sendPort: tileReceivePort.sendPort, region: input.region), onExit: tileReceivePort.sendPort, debugName: '[FMTC] Tile Coords Generator Thread', ); + final tileQueue = StreamQueue( input.rateLimit == null ? tileReceivePort diff --git a/lib/src/bulk_download/internal/rate_limited_stream.dart b/lib/src/bulk_download/internal/rate_limited_stream.dart index d7f7cb36..b80013b0 100644 --- a/lib/src/bulk_download/internal/rate_limited_stream.dart +++ b/lib/src/bulk_download/internal/rate_limited_stream.dart @@ -4,7 +4,6 @@ import 'dart:async'; /// Rate limiting extension, see [rateLimit] for more information -/// // TODO: check https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/throttle.html extension RateLimitedStream on Stream { /// Transforms a series of events to an output stream where a delay of at least diff --git a/lib/src/bulk_download/internal/tile_loops/count.dart b/lib/src/bulk_download/internal/tile_loops/count.dart index 358b3ab6..648ff93e 100644 --- a/lib/src/bulk_download/internal/tile_loops/count.dart +++ b/lib/src/bulk_download/internal/tile_loops/count.dart @@ -33,9 +33,7 @@ class TileCounters { /// Returns the number of tiles within a [DownloadableRegion] with generic type /// [RectangleRegion] @internal - static int rectangleTiles(DownloadableRegion region) { - region as DownloadableRegion; - + static int rectangleTiles(DownloadableRegion region) { final northWest = region.originalRegion.bounds.northWest; final southEast = region.originalRegion.bounds.southEast; @@ -62,9 +60,7 @@ class TileCounters { /// Returns the number of tiles within a [DownloadableRegion] with generic type /// [CircleRegion] @internal - static int circleTiles(DownloadableRegion region) { - region as DownloadableRegion; - + static int circleTiles(DownloadableRegion region) { int numberOfTiles = 0; final edgeTile = const Distance(roundResult: false).offset( @@ -110,9 +106,7 @@ class TileCounters { /// Returns the number of tiles within a [DownloadableRegion] with generic type /// [LineRegion] @internal - static int lineTiles(DownloadableRegion region) { - region as DownloadableRegion; - + static int lineTiles(DownloadableRegion region) { // Overlap algorithm originally in Python, available at // https://stackoverflow.com/a/56962827/11846040 bool overlap(_Polygon a, _Polygon b) { @@ -252,9 +246,9 @@ class TileCounters { /// Returns the number of tiles within a [DownloadableRegion] with generic type /// [CustomPolygonRegion] @internal - static int customPolygonTiles(DownloadableRegion region) { - region as DownloadableRegion; - + static int customPolygonTiles( + DownloadableRegion region, + ) { final customPolygonOutline = region.originalRegion.outline; int numberOfTiles = 0; @@ -314,4 +308,29 @@ class TileCounters { return _trimToRange(region, numberOfTiles); } + + /// Returns the number of tiles within a [DownloadableRegion] with generic type + /// [MultiRegion] + @internal + static int multiTiles(DownloadableRegion region) => + region.originalRegion.regions + .map( + (subRegion) => subRegion + .toDownloadable( + minZoom: region.minZoom, + maxZoom: region.maxZoom, + options: region.options, + start: region.start, + end: region.end, + crs: region.crs, + ) + .when( + rectangle: rectangleTiles, + circle: circleTiles, + line: lineTiles, + customPolygon: customPolygonTiles, + multi: multiTiles, + ), + ) + .sum; } diff --git a/lib/src/bulk_download/internal/tile_loops/generate.dart b/lib/src/bulk_download/internal/tile_loops/generate.dart index 8c90cc21..832e2f5f 100644 --- a/lib/src/bulk_download/internal/tile_loops/generate.dart +++ b/lib/src/bulk_download/internal/tile_loops/generate.dart @@ -27,16 +27,25 @@ class TileGenerators { /// generic type [RectangleRegion] @internal static Future rectangleTiles( - ({SendPort sendPort, DownloadableRegion region}) input, - ) async { - final region = input.region as DownloadableRegion; + ({SendPort sendPort, DownloadableRegion region}) input, { + StreamQueue? multiRequestQueue, + }) async { + final region = input.region; + final sendPort = input.sendPort; + final inMulti = multiRequestQueue != null; + + final StreamQueue requestQueue; + if (inMulti) { + requestQueue = multiRequestQueue; + } else { + final receivePort = ReceivePort(); + sendPort.send(receivePort.sendPort); + requestQueue = StreamQueue(receivePort); + } + final northWest = region.originalRegion.bounds.northWest; final southEast = region.originalRegion.bounds.southEast; - final receivePort = ReceivePort(); - input.sendPort.send(receivePort.sendPort); - final requestQueue = StreamQueue(receivePort); - int tileCounter = -1; final start = region.start - 1; final end = (region.end ?? double.infinity) - 1; @@ -56,28 +65,39 @@ class TileGenerators { for (int y = nwPoint.y; y <= sePoint.y; y++) { tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } await requestQueue.next; - input.sendPort.send((x, y, zoomLvl.toInt())); + sendPort.send((x, y, zoomLvl.toInt())); } } } - Isolate.exit(); + if (!inMulti) Isolate.exit(); } /// Generate the coordinates of each tile within a [DownloadableRegion] with /// generic type [CircleRegion] @internal static Future circleTiles( - ({SendPort sendPort, DownloadableRegion region}) input, - ) async { - final region = input.region as DownloadableRegion; - - final receivePort = ReceivePort(); - input.sendPort.send(receivePort.sendPort); - final requestQueue = StreamQueue(receivePort); + ({SendPort sendPort, DownloadableRegion region}) input, { + StreamQueue? multiRequestQueue, + }) async { + final region = input.region; + final sendPort = input.sendPort; + final inMulti = multiRequestQueue != null; + + final StreamQueue requestQueue; + if (inMulti) { + requestQueue = multiRequestQueue; + } else { + final receivePort = ReceivePort(); + sendPort.send(receivePort.sendPort); + requestQueue = StreamQueue(receivePort); + } int tileCounter = -1; final start = region.start - 1; @@ -108,10 +128,13 @@ class TileGenerators { if (radius == 0) { tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } await requestQueue.next; - input.sendPort.send((centerTile.x, centerTile.y, zoomLvl)); + sendPort.send((centerTile.x, centerTile.y, zoomLvl)); continue; } @@ -119,27 +142,43 @@ class TileGenerators { if (radius == 1) { tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; - input.sendPort.send((centerTile.x, centerTile.y, zoomLvl)); + sendPort.send((centerTile.x, centerTile.y, zoomLvl)); tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; - input.sendPort.send((centerTile.x, centerTile.y - 1, zoomLvl)); + sendPort.send((centerTile.x, centerTile.y - 1, zoomLvl)); tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; - input.sendPort.send((centerTile.x - 1, centerTile.y, zoomLvl)); + sendPort.send((centerTile.x - 1, centerTile.y, zoomLvl)); tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; - input.sendPort.send((centerTile.x - 1, centerTile.y - 1, zoomLvl)); + sendPort.send((centerTile.x - 1, centerTile.y - 1, zoomLvl)); continue; } @@ -149,29 +188,37 @@ class TileGenerators { for (int dx = -mdx - 1; dx <= mdx; dx++) { tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; - input.sendPort.send((dx + centerTile.x, dy + centerTile.y, zoomLvl)); + sendPort.send((dx + centerTile.x, dy + centerTile.y, zoomLvl)); tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; - input.sendPort - .send((dx + centerTile.x, -dy - 1 + centerTile.y, zoomLvl)); + sendPort.send((dx + centerTile.x, -dy - 1 + centerTile.y, zoomLvl)); } } } - Isolate.exit(); + if (!inMulti) Isolate.exit(); } /// Generate the coordinates of each tile within a [DownloadableRegion] with /// generic type [LineRegion] @internal static Future lineTiles( - ({SendPort sendPort, DownloadableRegion region}) input, - ) async { + ({SendPort sendPort, DownloadableRegion region}) input, { + StreamQueue? multiRequestQueue, + }) async { // This took some time and is fairly complicated, so this is the overall explanation: // 1. Given 4 `LatLng` points, create a 'straight' rectangle around the 'rotated' rectangle, that can be defined with just 2 `LatLng` points // 2. Convert the straight rectangle into tile numbers, and loop through the same as `rectangleTiles` @@ -214,12 +261,20 @@ class TileGenerators { return true; } - final region = input.region as DownloadableRegion; - final lineOutline = region.originalRegion.toOutlines(1); + final region = input.region; + final sendPort = input.sendPort; + final inMulti = multiRequestQueue != null; + + final StreamQueue requestQueue; + if (inMulti) { + requestQueue = multiRequestQueue; + } else { + final receivePort = ReceivePort(); + sendPort.send(receivePort.sendPort); + requestQueue = StreamQueue(receivePort); + } - final receivePort = ReceivePort(); - input.sendPort.send(receivePort.sendPort); - final requestQueue = StreamQueue(receivePort); + final lineOutline = region.originalRegion.toOutlines(1); int tileCounter = -1; final start = region.start - 1; @@ -293,7 +348,10 @@ class TileGenerators { for (int y = straightRectangleNW.y; y <= straightRectangleSE.y; y++) { tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } final tile = _Polygon( Point(x, y), @@ -315,7 +373,7 @@ class TileGenerators { generatedTiles.add(tile.hashCode); foundOverlappingTile = true; await requestQueue.next; - input.sendPort.send((x, y, zoomLvl.toInt())); + sendPort.send((x, y, zoomLvl.toInt())); } else if (foundOverlappingTile) { break; } @@ -324,21 +382,31 @@ class TileGenerators { } } - Isolate.exit(); + if (!inMulti) Isolate.exit(); } /// Generate the coordinates of each tile within a [DownloadableRegion] with /// generic type [CustomPolygonRegion] @internal static Future customPolygonTiles( - ({SendPort sendPort, DownloadableRegion region}) input, - ) async { - final region = input.region as DownloadableRegion; - final customPolygonOutline = region.originalRegion.outline; - - final receivePort = ReceivePort(); - input.sendPort.send(receivePort.sendPort); - final requestQueue = StreamQueue(receivePort); + ({ + SendPort sendPort, + DownloadableRegion region + }) input, { + StreamQueue? multiRequestQueue, + }) async { + final region = input.region; + final sendPort = input.sendPort; + final inMulti = multiRequestQueue != null; + + final StreamQueue requestQueue; + if (inMulti) { + requestQueue = multiRequestQueue; + } else { + final receivePort = ReceivePort(); + sendPort.send(receivePort.sendPort); + requestQueue = StreamQueue(receivePort); + } int tileCounter = -1; final start = region.start - 1; @@ -349,7 +417,7 @@ class TileGenerators { zoomLvl++) { final allOutlineTiles = >{}; - final pointsOutline = customPolygonOutline + final pointsOutline = region.originalRegion.outline .map((e) => region.crs.latLngToPoint(e, zoomLvl).floor()); for (final triangle in Earcut.triangulateFromPoints( @@ -392,7 +460,7 @@ class TileGenerators { for (int x = xsMin; x <= xsMax; x++) { await requestQueue.next; - input.sendPort.send((x, y, zoomLvl.toInt())); + sendPort.send((x, y, zoomLvl.toInt())); } } } @@ -400,13 +468,72 @@ class TileGenerators { for (final Point(:x, :y) in allOutlineTiles) { tileCounter++; if (tileCounter < start) continue; - if (tileCounter > end) Isolate.exit(); + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } await requestQueue.next; - input.sendPort.send((x, y, zoomLvl.toInt())); + sendPort.send((x, y, zoomLvl.toInt())); } } - Isolate.exit(); + if (!inMulti) Isolate.exit(); + } + + /// Generate the coordinates of each tile within a [DownloadableRegion] with + /// generic type [MultiRegion] + @internal + static Future multiTiles( + ({SendPort sendPort, DownloadableRegion region}) input, { + StreamQueue? multiRequestQueue, + }) async { + final region = input.region; + final inMulti = multiRequestQueue != null; + + final StreamQueue requestQueue; + if (inMulti) { + requestQueue = multiRequestQueue; + } else { + final receivePort = ReceivePort(); + input.sendPort.send(receivePort.sendPort); + requestQueue = StreamQueue(receivePort); + } + + for (final subRegion in region.originalRegion.regions) { + await subRegion + .toDownloadable( + minZoom: region.minZoom, + maxZoom: region.maxZoom, + options: region.options, + start: region.start, + end: region.end, + crs: region.crs, + ) + .when( + rectangle: (region) => rectangleTiles( + (sendPort: input.sendPort, region: region), + multiRequestQueue: requestQueue, + ), + circle: (region) => circleTiles( + (sendPort: input.sendPort, region: region), + multiRequestQueue: requestQueue, + ), + line: (region) => lineTiles( + (sendPort: input.sendPort, region: region), + multiRequestQueue: requestQueue, + ), + customPolygon: (region) => customPolygonTiles( + (sendPort: input.sendPort, region: region), + multiRequestQueue: requestQueue, + ), + multi: (region) => multiTiles( + (sendPort: input.sendPort, region: region), + multiRequestQueue: requestQueue, + ), + ); + } + + if (!inMulti) Isolate.exit(); } } diff --git a/lib/src/regions/base_region.dart b/lib/src/regions/base_region.dart index aef3d4ec..78b7d443 100644 --- a/lib/src/regions/base_region.dart +++ b/lib/src/regions/base_region.dart @@ -29,18 +29,43 @@ sealed class BaseRegion { /// - [CustomPolygonRegion] const BaseRegion(); - /// Output a value of type [T] dependent on `this` and its type + /// Output a value of type [T] the type of this region + /// + /// Requires all region types to have a defined handler. See [maybeWhen] for + /// the equivalent where this is not required. T when({ required T Function(RectangleRegion rectangle) rectangle, required T Function(CircleRegion circle) circle, required T Function(LineRegion line) line, required T Function(CustomPolygonRegion customPolygon) customPolygon, + required T Function(MultiRegion multi) multi, + }) => + maybeWhen( + rectangle: rectangle, + circle: circle, + line: line, + customPolygon: customPolygon, + multi: multi, + )!; + + /// Output a value of type [T] the type of this region + /// + /// If the specified method is not defined for the type of region which this + /// region is, `null` will be returned. + T? maybeWhen({ + T Function(RectangleRegion rectangle)? rectangle, + T Function(CircleRegion circle)? circle, + T Function(LineRegion line)? line, + T Function(CustomPolygonRegion customPolygon)? customPolygon, + T Function(MultiRegion multi)? multi, }) => switch (this) { - RectangleRegion() => rectangle(this as RectangleRegion), - CircleRegion() => circle(this as CircleRegion), - LineRegion() => line(this as LineRegion), - CustomPolygonRegion() => customPolygon(this as CustomPolygonRegion), + RectangleRegion() => rectangle?.call(this as RectangleRegion), + CircleRegion() => circle?.call(this as CircleRegion), + LineRegion() => line?.call(this as LineRegion), + CustomPolygonRegion() => + customPolygon?.call(this as CustomPolygonRegion), + MultiRegion() => multi?.call(this as MultiRegion), }; /// Generate the [DownloadableRegion] ready for bulk downloading diff --git a/lib/src/regions/downloadable_region.dart b/lib/src/regions/downloadable_region.dart index 8ef5d3f0..5a8d4bb3 100644 --- a/lib/src/regions/downloadable_region.dart +++ b/lib/src/regions/downloadable_region.dart @@ -75,6 +75,9 @@ class DownloadableRegion { ); /// Output a value of type [T] dependent on [originalRegion] and its type [R] + /// + /// Requires all region types to have a defined handler. See [maybeWhen] for + /// the equivalent where this is not required. T when({ required T Function(DownloadableRegion rectangle) rectangle, @@ -82,12 +85,34 @@ class DownloadableRegion { required T Function(DownloadableRegion line) line, required T Function(DownloadableRegion customPolygon) customPolygon, + required T Function(DownloadableRegion multi) multi, + }) => + maybeWhen( + rectangle: rectangle, + circle: circle, + line: line, + customPolygon: customPolygon, + multi: multi, + )!; + + /// Output a value of type [T] dependent on [originalRegion] and its type [R] + /// + /// If the specified method is not defined for the type of region which this + /// region is, `null` will be returned. + T? maybeWhen({ + T Function(DownloadableRegion rectangle)? rectangle, + T Function(DownloadableRegion circle)? circle, + T Function(DownloadableRegion line)? line, + T Function(DownloadableRegion customPolygon)? + customPolygon, + T Function(DownloadableRegion multi)? multi, }) => switch (originalRegion) { - RectangleRegion() => rectangle(_cast()), - CircleRegion() => circle(_cast()), - LineRegion() => line(_cast()), - CustomPolygonRegion() => customPolygon(_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 357e6d58..0bd18139 100644 --- a/lib/src/regions/recovered_region.dart +++ b/lib/src/regions/recovered_region.dart @@ -3,23 +3,13 @@ part of '../../flutter_map_tile_caching.dart'; -/// A mixture between [BaseRegion] and [DownloadableRegion] containing all the -/// salvaged data from a recovered download +/// A wrapper containing recovery & some downloadable region information, around +/// a [DownloadableRegion] /// /// See [RootRecovery] for information about the recovery system. -/// -/// The availability of [bounds], [line], [center] & [radius] depend on the -/// represented type of the recovered region. Use [toDownloadable] to restore a -/// valid [DownloadableRegion]. -class RecoveredRegion { - /// A mixture between [BaseRegion] and [DownloadableRegion] containing all the - /// salvaged data from a recovered download - /// - /// See [RootRecovery] for information about the recovery system. - /// - /// The availability of [bounds], [line], [center] & [radius] depend on the - /// represented type of the recovered region. Use [toDownloadable] to restore - /// a valid [DownloadableRegion]. +class RecoveredRegion { + /// Create a wrapper containing recovery information around a + /// [DownloadableRegion] @internal RecoveredRegion({ required this.id, @@ -29,10 +19,7 @@ class RecoveredRegion { required this.maxZoom, required this.start, required this.end, - required this.bounds, - required this.center, - required this.line, - required this.radius, + required this.region, }); /// A unique ID created for every bulk download operation @@ -62,27 +49,8 @@ class RecoveredRegion { /// region, as determined by [StoreDownload.check]. final int end; - /// Corresponds to [RectangleRegion.bounds] - final LatLngBounds? bounds; - - /// Corrresponds to [LineRegion.line] & [CustomPolygonRegion.outline] - final List? line; - - /// Corrresponds to [CircleRegion.center] - final LatLng? center; - - /// Corrresponds to [LineRegion.radius] & [CircleRegion.radius] - final double? radius; - - /// Convert this region into a [BaseRegion] - /// - /// Determine which type of [BaseRegion] using [BaseRegion.when]. - BaseRegion toRegion() { - if (bounds != null) return RectangleRegion(bounds!); - if (center != null) return CircleRegion(center!, radius!); - if (line != null && radius != null) return LineRegion(line!, radius!); - return CustomPolygonRegion(line!); - } + /// The [BaseRegion] which was recovered + final R region; /// Convert this region into a [DownloadableRegion] DownloadableRegion toDownloadable( @@ -90,7 +58,7 @@ class RecoveredRegion { Crs crs = const Epsg3857(), }) => DownloadableRegion._( - toRegion(), + region, minZoom: minZoom, maxZoom: maxZoom, options: options, diff --git a/lib/src/regions/shapes/circle.dart b/lib/src/regions/shapes/circle.dart index 024563a2..1af03a9b 100644 --- a/lib/src/regions/shapes/circle.dart +++ b/lib/src/regions/shapes/circle.dart @@ -4,16 +4,9 @@ part of '../../../flutter_map_tile_caching.dart'; /// A geographically circular region based off a [center] coord and [radius] -/// -/// It can be converted to a: -/// - [DownloadableRegion] for downloading: [toDownloadable] -/// - list of [LatLng]s forming the outline: [toOutline] class CircleRegion extends BaseRegion { - /// A geographically circular region based off a [center] coord and [radius] - /// - /// It can be converted to a: - /// - [DownloadableRegion] for downloading: [toDownloadable] - /// - list of [LatLng]s forming the outline: [toOutline] + /// Create a geographically circular region based off a [center] coord and + /// [radius] const CircleRegion(this.center, this.radius); /// Center coordinate diff --git a/lib/src/regions/shapes/custom_polygon.dart b/lib/src/regions/shapes/custom_polygon.dart index 62068d93..e863f214 100644 --- a/lib/src/regions/shapes/custom_polygon.dart +++ b/lib/src/regions/shapes/custom_polygon.dart @@ -4,16 +4,9 @@ part of '../../../flutter_map_tile_caching.dart'; /// A geographical region who's outline is defined by a list of coordinates -/// -/// It can be converted to a: -/// - [DownloadableRegion] for downloading: [toDownloadable] -/// - list of [LatLng]s forming the outline: [toOutline] class CustomPolygonRegion extends BaseRegion { - /// A geographical region who's outline is defined by a list of coordinates - /// - /// It can be converted to a: - /// - [DownloadableRegion] for downloading: [toDownloadable] - /// - list of [LatLng]s forming the outline: [toOutline] + /// Create a geographical region who's outline is defined by a list of + /// coordinates const CustomPolygonRegion(this.outline); /// The outline coordinates @@ -47,5 +40,5 @@ class CustomPolygonRegion extends BaseRegion { (other is CustomPolygonRegion && listEquals(outline, other.outline)); @override - int get hashCode => outline.hashCode; + int get hashCode => Object.hashAll(outline); } diff --git a/lib/src/regions/shapes/line.dart b/lib/src/regions/shapes/line.dart index 7fdd6ab4..0ac03f41 100644 --- a/lib/src/regions/shapes/line.dart +++ b/lib/src/regions/shapes/line.dart @@ -4,16 +4,9 @@ part of '../../../flutter_map_tile_caching.dart'; /// A geographically line/locus region based off a list of coords and a [radius] -/// -/// It can be converted to a: -/// - [DownloadableRegion] for downloading: [toDownloadable] -/// - list of [LatLng]s forming the outline: [LineRegion.toOutlines] class LineRegion extends BaseRegion { - /// A geographically line/locus region based off a list of coords and a [radius] - /// - /// It can be converted to a: - /// - [DownloadableRegion] for downloading: [toDownloadable] - /// - list of [LatLng]s forming the outline: [LineRegion.toOutlines] + /// Create a geographically line/locus region based off a list of coords and a + /// [radius] const LineRegion(this.line, this.radius); /// The center line defined by a list of coordinates @@ -112,5 +105,5 @@ class LineRegion extends BaseRegion { listEquals(line, other.line)); @override - int get hashCode => Object.hash(line, radius); + int get hashCode => Object.hashAll([...line, radius]); } diff --git a/lib/src/regions/shapes/multi.dart b/lib/src/regions/shapes/multi.dart new file mode 100644 index 00000000..133500de --- /dev/null +++ b/lib/src/regions/shapes/multi.dart @@ -0,0 +1,54 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../../flutter_map_tile_caching.dart'; + +/// A region formed from multiple other [BaseRegion]s +/// +/// When downloading, each sub-region specified in [regions] is downloaded +/// consecutively. [MultiRegion]s may be nested. +/// +/// [toOutline] is not supported and will always throw. +class MultiRegion extends BaseRegion { + /// Create a region formed from multiple other [BaseRegion]s + const MultiRegion(this.regions); + + /// List of sub-regions + final List regions; + + @override + DownloadableRegion toDownloadable({ + required int minZoom, + required int maxZoom, + required TileLayer options, + int start = 1, + int? end, + Crs crs = const Epsg3857(), + }) => + DownloadableRegion._( + this, + minZoom: minZoom, + maxZoom: maxZoom, + options: options, + start: start, + end: end, + crs: crs, + ); + + /// [MultiRegion]s do not support [toOutline], as it would not be useful, + /// and it is out of scope to implement a convex-hull for no real purpose + /// + /// Instead, use [BaseRegion.toOutline] on each individual sub-region in + /// [regions]. + @override + Never toOutline() => + throw UnsupportedError('`MultiRegion`s do not support `toOutline`'); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MultiRegion && listEquals(regions, other.regions)); + + @override + int get hashCode => Object.hashAll(regions); +} diff --git a/lib/src/regions/shapes/rectangle.dart b/lib/src/regions/shapes/rectangle.dart index c46310f0..0574b99c 100644 --- a/lib/src/regions/shapes/rectangle.dart +++ b/lib/src/regions/shapes/rectangle.dart @@ -5,17 +5,10 @@ part of '../../../flutter_map_tile_caching.dart'; /// A geographically rectangular region based off coordinate bounds /// -/// Rectangles do not support skewing into parallelograms. -/// -/// It can be converted to a: -/// - [DownloadableRegion] for downloading: [toDownloadable] -/// - list of [LatLng]s forming the outline: [toOutline] +/// Does not support skewing into parallelograms: use [CustomPolygonRegion] +/// instead. class RectangleRegion extends BaseRegion { - /// A geographically rectangular region based off coordinate bounds - /// - /// It can be converted to a: - /// - [DownloadableRegion] for downloading: [toDownloadable] - /// - list of [LatLng]s forming the outline: [toOutline] + /// Create a geographically rectangular region based off coordinate bounds const RectangleRegion(this.bounds); /// The coordinate bounds diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 8635af99..db1c39c8 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -255,11 +255,12 @@ class StoreDownload { /// /// Returns the number of tiles. Future check(DownloadableRegion region) => compute( - region.when( - rectangle: (_) => TileCounters.rectangleTiles, - circle: (_) => TileCounters.circleTiles, - line: (_) => TileCounters.lineTiles, - customPolygon: (_) => TileCounters.customPolygonTiles, + (region) => region.when( + rectangle: TileCounters.rectangleTiles, + circle: TileCounters.circleTiles, + line: TileCounters.lineTiles, + customPolygon: TileCounters.customPolygonTiles, + multi: TileCounters.multiTiles, ), region, ); diff --git a/pubspec.yaml b/pubspec.yaml index c0fd316e..b71241ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 10.0.0-dev.4 +version: 10.0.0-dev.5 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues @@ -33,18 +33,18 @@ dependencies: flat_buffers: ^23.5.26 flutter: sdk: flutter - flutter_map: ^7.0.0 + flutter_map: ^7.0.2 http: ^1.2.2 latlong2: ^0.9.1 - meta: ^1.12.0 - objectbox: ^4.0.1 - objectbox_flutter_libs: ^4.0.1 + meta: ^1.15.0 + objectbox: ^4.0.2 + objectbox_flutter_libs: ^4.0.2 path: ^1.9.0 path_provider: ^2.1.4 dev_dependencies: - build_runner: ^2.4.11 - objectbox_generator: ^4.0.1 + build_runner: ^2.4.12 + objectbox_generator: ^4.0.2 test: ^1.25.8 flutter: null diff --git a/test/region_tile_test.dart b/test/region_tile_test.dart index 30bf80e7..ec62002f 100644 --- a/test/region_tile_test.dart +++ b/test/region_tile_test.dart @@ -16,16 +16,29 @@ void main() { Future countByGenerator(DownloadableRegion region) async { final tileReceivePort = ReceivePort(); final tileIsolate = await Isolate.spawn( - region.when( - rectangle: (_) => TileGenerators.rectangleTiles, - circle: (_) => TileGenerators.circleTiles, - line: (_) => TileGenerators.lineTiles, - customPolygon: (_) => TileGenerators.customPolygonTiles, + (({SendPort sendPort, DownloadableRegion region}) input) => + input.region.when( + rectangle: (region) => TileGenerators.rectangleTiles( + (sendPort: input.sendPort, region: region), + ), + circle: (region) => TileGenerators.circleTiles( + (sendPort: input.sendPort, region: region), + ), + line: (region) => TileGenerators.lineTiles( + (sendPort: input.sendPort, region: region), + ), + customPolygon: (region) => TileGenerators.customPolygonTiles( + (sendPort: input.sendPort, region: region), + ), + multi: (region) => TileGenerators.multiTiles( + (sendPort: input.sendPort, region: region), + ), ), (sendPort: tileReceivePort.sendPort, region: region), onExit: tileReceivePort.sendPort, debugName: '[FMTC] Tile Coords Generator Thread', ); + late final SendPort requestTilePort; int evts = -1; @@ -81,6 +94,23 @@ void main() { expect(tiles, 179196); }, ); + + final multiRegion = MultiRegion( + [ + rectRegion.originalRegion, + rectRegion.originalRegion, + ], + ).toDownloadable(minZoom: 1, maxZoom: 16, options: TileLayer()); + + test( + '`MultiRegion` Match Counter', + () => expect(TileCounters.multiTiles(multiRegion), 179196 * 2), + ); + + test( + '`MultiRegion` Match Generator', + () async => expect(await countByGenerator(multiRegion), 179196 * 2), + ); }, timeout: const Timeout(Duration(minutes: 1)), ); From f61e355e268dbf6220c4527a6f1a7693ff62eecd Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 15 Aug 2024 23:49:37 +0100 Subject: [PATCH 39/97] Improved (by a lot) tile loading with `_FMTCImageProvider` when fetching from the network, by removing `chunkEvents` support & manual streamed byte collection Added `cacheFetchDuration` & `networkFetchDuration` to `TileLoadingInterceptorResult` Improved stability of `TileLoadingInterceptorResult` by internally collecting values in temporary object before compiling and constructing result Added `CacheBehavior` deprecated alias for `BrowseLoadingStrategy` Removed `FMTCBrowsingError.request` & converted `.response` to `Response` (from `StreamedResponse`) Fixed issues with documentation (appears to be a bug in dartdoc where some templates/macros are incorrectly inaccessible) --- .../components/debugging_tile_builder.dart | 22 ++++-- lib/flutter_map_tile_caching.dart | 1 - .../impls/objectbox/backend/backend.dart | 12 +-- lib/src/backend/impls/web_noop/backend.dart | 29 ++++++- .../image_provider/browsing_errors.dart | 9 +-- .../image_provider/image_provider.dart | 73 +++++++++--------- .../image_provider/internal_get_bytes.dart | 57 ++++++-------- .../tile_loading_interceptor/result.dart | 76 +++++++++++++++---- .../providers/tile_provider/strategies.dart | 4 + .../tile_provider/tile_provider.dart | 6 -- 10 files changed, 168 insertions(+), 121 deletions(-) diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart b/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart index d49faf64..d8f4e5df 100644 --- a/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart +++ b/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart @@ -61,9 +61,10 @@ class DebuggingTileBuilder extends StatelessWidget { ); } - return OverflowBox( - child: Padding( - padding: const EdgeInsets.all(6), + return Padding( + padding: const EdgeInsets.all(8), + child: FittedBox( + fit: BoxFit.scaleDown, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -76,14 +77,21 @@ class DebuggingTileBuilder extends StatelessWidget { if (info.error case final error?) Text( error is FMTCBrowsingError - ? error.type.name - : 'Unknown error', + ? '`${error.type.name}`' + : 'Unknown error (${error.runtimeType})', textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.red, + fontStyle: FontStyle.italic, + ), ), if (info.resultPath case final result?) ...[ Text( - "'${result.name}' in " - '${tile.loadFinishedAt == null || tile.loadStarted == null ? 'Loading...' : '${tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms'}\n', + '`${result.name}` in ${tile.loadFinishedAt == null || tile.loadStarted == null ? '...' : tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms', + textAlign: TextAlign.center, + ), + Text( + '(${info.cacheFetchDuration.inMilliseconds} ms cache${info.networkFetchDuration == null ? ')' : ' | ${info.networkFetchDuration!.inMilliseconds} ms network)'}\n', textAlign: TextAlign.center, ), if (info.existingStores case final existingStores?) diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 15dc19ca..582b44f8 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -15,7 +15,6 @@ import 'dart:collection'; import 'dart:io'; import 'dart:isolate'; import 'dart:math' as math; -import 'dart:typed_data'; import 'dart:ui'; import 'package:async/async.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/backend.dart b/lib/src/backend/impls/objectbox/backend/backend.dart index 93c244c8..0512c50d 100644 --- a/lib/src/backend/impls/objectbox/backend/backend.dart +++ b/lib/src/backend/impls/objectbox/backend/backend.dart @@ -32,23 +32,19 @@ part 'internal_workers/shared.dart'; part 'internal_workers/thread_safe.dart'; part 'internal.dart'; -/// {@template fmtc.backend.objectbox} /// Implementation of [FMTCBackend] that uses ObjectBox as the storage database /// /// On web, this redirects to a no-op implementation that throws /// [UnsupportedError]s when attempting to use [initialise] or [uninitialise], /// and [RootUnavailable] when trying to use any other method. -/// {@endtemplate} final class FMTCObjectBoxBackend implements FMTCBackend { /// {@macro fmtc.backend.initialise} /// - /// {@template fmtc.backend.objectbox.initialise} - /// /// --- /// /// [maxDatabaseSize] is the maximum size the database file can grow - /// to, in KB. Exceeding it throws [DbFullException] on write operations. - /// Defaults to 10 GB (10000000 KB). + /// to, in KB. Exceeding it throws [DbFullException] (from + /// 'package:objectbox') on write operations. Defaults to 10 GB (10000000 KB). /// /// [macosApplicationGroup] should be set when creating a sandboxed macOS app, /// specify the application group (of less than 20 chars). See @@ -60,7 +56,6 @@ final class FMTCObjectBoxBackend implements FMTCBackend { /// thread. /// /// Avoid using [useInMemoryDatabase] outside of testing purposes. - /// {@endtemplate} @override Future initialise({ String? rootDirectory, @@ -79,14 +74,11 @@ final class FMTCObjectBoxBackend implements FMTCBackend { /// {@macro fmtc.backend.uninitialise} /// - /// {@template fmtc.backend.objectbox.uninitialise} - /// /// If [immediate] is `true`, any operations currently underway will be lost, /// as the worker will be killed as quickly as possible (not necessarily /// instantly). /// If `false`, all operations currently underway will be allowed to complete, /// but any operations started after this method call will be lost. - /// {@endtemplate} @override Future uninitialise({ bool deleteRoot = false, diff --git a/lib/src/backend/impls/web_noop/backend.dart b/lib/src/backend/impls/web_noop/backend.dart index 723604f6..2a70486a 100644 --- a/lib/src/backend/impls/web_noop/backend.dart +++ b/lib/src/backend/impls/web_noop/backend.dart @@ -6,14 +6,33 @@ import 'package:meta/meta.dart'; import '../../../../flutter_map_tile_caching.dart'; -/// {@macro fmtc.backend.objectbox} +/// Implementation of [FMTCBackend] that uses ObjectBox as the storage database +/// +/// On web, this redirects to a no-op implementation that throws +/// [UnsupportedError]s when attempting to use [initialise] or [uninitialise], +/// and [RootUnavailable] when trying to use any other method. final class FMTCObjectBoxBackend implements FMTCBackend { static const _noopMessage = 'FMTC is not supported on non-FFI platforms by default'; /// {@macro fmtc.backend.initialise} /// - /// {@macro fmtc.backend.objectbox.initialise} + /// --- + /// + /// [maxDatabaseSize] is the maximum size the database file can grow + /// to, in KB. Exceeding it throws `DbFullException` (from + /// 'package:objectbox') on write operations. Defaults to 10 GB (10000000 KB). + /// + /// [macosApplicationGroup] should be set when creating a sandboxed macOS app, + /// specify the application group (of less than 20 chars). See + /// [the ObjectBox docs](https://docs.objectbox.io/getting-started) for + /// details. + /// + /// [rootIsolateToken] should only be used in exceptional circumstances where + /// this backend is being initialised in a seperate isolate (or background) + /// thread. + /// + /// Avoid using [useInMemoryDatabase] outside of testing purposes. @override Future initialise({ String? rootDirectory, @@ -26,7 +45,11 @@ final class FMTCObjectBoxBackend implements FMTCBackend { /// {@macro fmtc.backend.uninitialise} /// - /// {@macro fmtc.backend.objectbox.uninitialise} + /// If [immediate] is `true`, any operations currently underway will be lost, + /// as the worker will be killed as quickly as possible (not necessarily + /// instantly). + /// If `false`, all operations currently underway will be allowed to complete, + /// but any operations started after this method call will be lost. @override Future uninitialise({ bool deleteRoot = false, diff --git a/lib/src/providers/image_provider/browsing_errors.dart b/lib/src/providers/image_provider/browsing_errors.dart index b75eff40..042ab465 100644 --- a/lib/src/providers/image_provider/browsing_errors.dart +++ b/lib/src/providers/image_provider/browsing_errors.dart @@ -34,7 +34,6 @@ class FMTCBrowsingError implements Exception { required this.type, required this.networkUrl, required this.storageSuitableUID, - this.request, this.response, this.originalError, }) : message = '${type.explanation} ${type.resolution}'; @@ -59,18 +58,12 @@ class FMTCBrowsingError implements Exception { /// [FMTCTileProvider.urlTransformer] on [networkUrl] final String storageSuitableUID; - /// If available, the attempted HTTP request - /// - /// Will be available if [type] is not - /// [FMTCBrowsingErrorType.missingInCacheOnlyMode]. - final Request? request; - /// If available, the HTTP response streamed from the server /// /// Will be available if [type] is /// [FMTCBrowsingErrorType.negativeFetchResponse] or /// [FMTCBrowsingErrorType.invalidImageData]. - final StreamedResponse? response; + final Response? response; /// If available, the error object that was caught when attempting the HTTP /// request diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index 9375f90b..41a138e6 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -41,32 +41,25 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { ImageStreamCompleter loadImage( _FMTCImageProvider key, ImageDecoderCallback decode, - ) { - // Closed by `getBytes` - // ignore: close_sinks - final chunkEvents = StreamController(); - - return MultiFrameImageStreamCompleter( - codec: getBytes( - coords: coords, - options: options, - provider: provider, - key: key, - chunkEvents: chunkEvents, - finishedLoadingBytes: finishedLoadingBytes, - startedLoading: startedLoading, - requireValidImage: true, - ).then(ImmutableBuffer.fromUint8List).then((v) => decode(v)), - chunkEvents: chunkEvents.stream, - scale: 1, - debugLabel: coords.toString(), - informationCollector: () => [ - DiagnosticsProperty('Store names', provider.storeNames), - DiagnosticsProperty('Tile coordinates', coords), - DiagnosticsProperty('Current provider', key), - ], - ); - } + ) => + MultiFrameImageStreamCompleter( + codec: getBytes( + coords: coords, + options: options, + provider: provider, + key: key, + finishedLoadingBytes: finishedLoadingBytes, + startedLoading: startedLoading, + requireValidImage: true, + ).then(ImmutableBuffer.fromUint8List).then((v) => decode(v)), + scale: 1, + debugLabel: coords.toString(), + informationCollector: () => [ + DiagnosticsProperty('Store names', provider.storeNames), + DiagnosticsProperty('Tile coordinates', coords), + DiagnosticsProperty('Current provider', key), + ], + ); /// {@macro fmtc.imageProvider.getBytes} static Future getBytes({ @@ -74,14 +67,12 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { required TileLayer options, required FMTCTileProvider provider, Object? key, - StreamController? chunkEvents, void Function()? startedLoading, void Function()? finishedLoadingBytes, bool requireValidImage = false, }) async { - final currentTLIR = provider.tileLoadingInterceptor != null - ? TileLoadingInterceptorResult._() - : null; + final currentTLIR = + provider.tileLoadingInterceptor != null ? _TLIRConstructor._() : null; void close([Object? error]) { finishedLoadingBytes?.call(); @@ -89,18 +80,27 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { if (key != null && error != null) { scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); } - if (chunkEvents != null) { - unawaited(chunkEvents.close()); - } if (currentTLIR != null) { currentTLIR.error = error; - if (error != null) currentTLIR.resultPath = null; provider.tileLoadingInterceptor! - ..value[coords] = currentTLIR + ..value[coords] = TileLoadingInterceptorResult._( + resultPath: currentTLIR.resultPath, + error: currentTLIR.error, + networkUrl: currentTLIR.networkUrl, + storageSuitableUID: currentTLIR.storageSuitableUID, + existingStores: currentTLIR.existingStores, + tileExistsInUnspecifiedStoresOnly: + currentTLIR.tileExistsInUnspecifiedStoresOnly, + needsUpdating: currentTLIR.needsUpdating, + hitOrMiss: currentTLIR.hitOrMiss, + storesWriteResult: currentTLIR.storesWriteResult, + cacheFetchDuration: currentTLIR.cacheFetchDuration, + networkFetchDuration: currentTLIR.networkFetchDuration, + ) // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - ..notifyListeners(); + ..notifyListeners(); // `Map` is mutable, so must notify manually } } @@ -112,7 +112,6 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { coords: coords, options: options, provider: provider, - chunkEvents: chunkEvents, requireValidImage: requireValidImage, currentTLIR: currentTLIR, ); diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index da64a1e7..9a9d2541 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -7,9 +7,8 @@ Future _internalGetBytes({ required TileCoordinates coords, required TileLayer options, required FMTCTileProvider provider, - required StreamController? chunkEvents, required bool requireValidImage, - required TileLoadingInterceptorResult? currentTLIR, + required _TLIRConstructor? currentTLIR, }) async { void registerHit(List storeNames) { currentTLIR?.hitOrMiss = true; @@ -35,6 +34,9 @@ Future _internalGetBytes({ currentTLIR?.networkUrl = networkUrl; currentTLIR?.storageSuitableUID = matcherUrl; + late final DateTime cacheFetchStartTime; + if (currentTLIR != null) cacheFetchStartTime = DateTime.now(); + final ( tile: existingTile, intersectedStoreNames: intersectedExistingStores, @@ -44,8 +46,12 @@ Future _internalGetBytes({ storeNames: provider._getSpecifiedStoresOrNull(), ); - currentTLIR?.existingStores = - allExistingStores.isEmpty ? null : allExistingStores; + currentTLIR?.cacheFetchDuration = + DateTime.now().difference(cacheFetchStartTime); + + if (allExistingStores.isNotEmpty) { + currentTLIR?.existingStores = allExistingStores; + } final tileExistsInUnspecifiedStoresOnly = existingTile != null && provider.useOtherStoresAsFallbackOnly && @@ -79,7 +85,6 @@ Future _internalGetBytes({ !tileExistsInUnspecifiedStoresOnly) { currentTLIR?.resultPath = TileLoadingInterceptorResultPath.perfectFromStores; - currentTLIR?.storesWriteResult = null; registerHit(intersectedExistingStores); return bytes!; @@ -91,7 +96,6 @@ Future _internalGetBytes({ if (existingTile != null) { currentTLIR?.resultPath = TileLoadingInterceptorResultPath.cacheOnlyFromOtherStores; - currentTLIR?.storesWriteResult = null; registerMiss(); return bytes!; @@ -118,15 +122,17 @@ Future _internalGetBytes({ } // Setup a network request for the tile & handle network exceptions - final request = http.Request('GET', Uri.parse(networkUrl)) - ..headers.addAll(provider.headers); - final http.StreamedResponse response; + final http.Response response; + + late final DateTime networkFetchStartTime; + if (currentTLIR != null) networkFetchStartTime = DateTime.now(); + try { - response = await provider.httpClient.send(request); + response = await provider.httpClient + .get(Uri.parse(networkUrl), headers: provider.headers); } catch (e) { if (existingTile != null) { currentTLIR?.resultPath = TileLoadingInterceptorResultPath.noFetch; - currentTLIR?.storesWriteResult = null; registerMiss(); return bytes!; @@ -138,16 +144,17 @@ Future _internalGetBytes({ : FMTCBrowsingErrorType.unknownFetchException, networkUrl: networkUrl, storageSuitableUID: matcherUrl, - request: request, originalError: e, ); } + currentTLIR?.networkFetchDuration = + DateTime.now().difference(networkFetchStartTime); + // Check whether the network response is not 200 OK if (response.statusCode != 200) { if (existingTile != null) { currentTLIR?.resultPath = TileLoadingInterceptorResultPath.noFetch; - currentTLIR?.storesWriteResult = null; registerMiss(); return bytes!; @@ -157,24 +164,10 @@ Future _internalGetBytes({ type: FMTCBrowsingErrorType.negativeFetchResponse, networkUrl: networkUrl, storageSuitableUID: matcherUrl, - request: request, response: response, ); } - // Extract the image bytes from the streamed network response - final bytesBuilder = BytesBuilder(copy: false); - await for (final byte in response.stream) { - bytesBuilder.add(byte); - chunkEvents?.add( - ImageChunkEvent( - cumulativeBytesLoaded: bytesBuilder.length, - expectedTotalBytes: response.contentLength, - ), - ); - } - final responseBytes = bytesBuilder.takeBytes(); - // Perform a secondary check to ensure that the bytes recieved actually // encode a valid image if (requireValidImage) { @@ -182,7 +175,7 @@ Future _internalGetBytes({ try { isValidImageData = (await (await instantiateImageCodec( - responseBytes, + response.bodyBytes, targetWidth: 8, targetHeight: 8, )) @@ -199,7 +192,6 @@ Future _internalGetBytes({ if (isValidImageData != null) { if (existingTile != null) { currentTLIR?.resultPath = TileLoadingInterceptorResultPath.noFetch; - currentTLIR?.storesWriteResult = null; registerMiss(); return bytes!; @@ -209,7 +201,6 @@ Future _internalGetBytes({ type: FMTCBrowsingErrorType.invalidImageData, networkUrl: networkUrl, storageSuitableUID: matcherUrl, - request: request, response: response, originalError: isValidImageData, ); @@ -252,7 +243,7 @@ Future _internalGetBytes({ ? provider.storeNames.keys.toList(growable: false) : null, url: matcherUrl, - bytes: responseBytes, + bytes: response.bodyBytes, // ignore: unawaited_futures )..then((result) { final createdIn = @@ -266,12 +257,10 @@ Future _internalGetBytes({ storeNames: createdIn.toList(growable: false), // TODO: Verify ); }); - } else { - currentTLIR?.storesWriteResult = null; } currentTLIR?.resultPath = TileLoadingInterceptorResultPath.fetched; registerMiss(); - return responseBytes; + return response.bodyBytes; } diff --git a/lib/src/providers/tile_loading_interceptor/result.dart b/lib/src/providers/tile_loading_interceptor/result.dart index 2e0ce430..e4712994 100644 --- a/lib/src/providers/tile_loading_interceptor/result.dart +++ b/lib/src/providers/tile_loading_interceptor/result.dart @@ -3,14 +3,43 @@ part of '../../../flutter_map_tile_caching.dart'; +/// A 'temporary' object that collects information from [_internalGetBytes] to +/// be used to construct a [TileLoadingInterceptorResult] +/// +/// See documentation on [TileLoadingInterceptorResult] for more information +class _TLIRConstructor { + _TLIRConstructor._(); + + TileLoadingInterceptorResultPath? resultPath; + Object? error; + late String networkUrl; + late String storageSuitableUID; + List? existingStores; + late bool tileExistsInUnspecifiedStoresOnly; + late bool needsUpdating; + bool? hitOrMiss; + Future>? storesWriteResult; + late Duration cacheFetchDuration; + Duration? networkFetchDuration; +} + /// Information useful to debug and record detailed statistics for the loading /// mechanisms and paths of a tile -/// -/// When an object of this type is emitted through a [TileLoadingInterceptorMap], -/// the tile will have finished loading (successfully or unsuccessfully), and all -/// fields/properties will be initialised and safe to read. +@immutable class TileLoadingInterceptorResult { - TileLoadingInterceptorResult._(); + const TileLoadingInterceptorResult._({ + required this.resultPath, + required this.error, + required this.networkUrl, + required this.storageSuitableUID, + required this.existingStores, + required this.tileExistsInUnspecifiedStoresOnly, + required this.needsUpdating, + required this.hitOrMiss, + required this.storesWriteResult, + required this.cacheFetchDuration, + required this.networkFetchDuration, + }); /// Indicates whether & how the tile completed loading successfully /// @@ -20,7 +49,7 @@ class TileLoadingInterceptorResult { /// /// See [didComplete] for a boolean result. If `null`, see [error] for the /// error/exception object. - late final TileLoadingInterceptorResultPath? resultPath; + final TileLoadingInterceptorResultPath? resultPath; /// Indicates whether & how the tile completed loading unsuccessfully /// @@ -30,7 +59,7 @@ class TileLoadingInterceptorResult { /// /// See [didComplete] for a boolean result. If `null`, see [resultPath] for the /// exact result path. - late final Object? error; + final Object? error; /// Indicates whether the tile completed loading successfully /// @@ -39,14 +68,14 @@ class TileLoadingInterceptorResult { bool get didComplete => resultPath != null; /// The requested URL of the tile (based on the [TileLayer.urlTemplate]) - late final String networkUrl; + final String networkUrl; /// The storage-suitable UID of the tile: the result of /// [FMTCTileProvider.urlTransformer] on [networkUrl] - late final String storageSuitableUID; + final String storageSuitableUID; /// If the tile already existed, the stores that it existed in/belonged to - late final List? existingStores; + final List? existingStores; /// Reflection of an internal indicator of the same name /// @@ -57,7 +86,7 @@ class TileLoadingInterceptorResult { /// `useOtherStoresAsFallbackOnly` && /// /// ``` - late final bool tileExistsInUnspecifiedStoresOnly; + final bool tileExistsInUnspecifiedStoresOnly; /// Reflection of an internal indicator of the same name /// @@ -70,10 +99,12 @@ class TileLoadingInterceptorResult { /// /// ) /// ``` - late final bool needsUpdating; + final bool needsUpdating; /// Whether a hit or miss was (or would have) been recorded - late final bool hitOrMiss; + /// + /// `null` if the tile did not complete loading successfully. + final bool? hitOrMiss; /// A mapping of all stores the tile was written to, to whether that tile was /// newly created in that store (not updated) @@ -81,6 +112,21 @@ class TileLoadingInterceptorResult { /// Is a future because the result must come from an asynchronously triggered /// database write operation. /// - /// `null` if no write operation was necessary/attempted. - late final Future>? storesWriteResult; + /// `null` if no write operation was necessary/attempted, or the tile did not + /// complete loading successfully. + final Future>? storesWriteResult; + + /// The duration of the operation used to attempt to read the existing tile + /// from the store/cache + /// + /// Even in [BrowseLoadingStrategy.onlineFirst] and where the tile is not used + /// from the local store/cache, the tile read attempt still occurs. + final Duration cacheFetchDuration; + + /// The duration of the operation used to attempt to fetch the tile from the + /// network. + /// + /// `null` if no network fetch was attempted, or the tile did not complete + /// loading successfully. + final Duration? networkFetchDuration; } diff --git a/lib/src/providers/tile_provider/strategies.dart b/lib/src/providers/tile_provider/strategies.dart index 69491582..c7ad21de 100644 --- a/lib/src/providers/tile_provider/strategies.dart +++ b/lib/src/providers/tile_provider/strategies.dart @@ -3,6 +3,10 @@ part of '../../../flutter_map_tile_caching.dart'; +/// Alias for [BrowseLoadingStrategy], to ease migration from v9 -> v10 +@Deprecated('`CacheBehavior` has been renamed to `BrowseLoadingStrategy`') +typedef CacheBehavior = BrowseLoadingStrategy; + /// Determines whether the network or cache is preferred during browse caching, /// and how to fallback /// diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index 50b45a4f..2412a5e9 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -237,10 +237,6 @@ class FMTCTileProvider extends TileProvider { /// [key] is used to control the [ImageCache], and should be set when in a /// context where [ImageProvider.obtainKey] is available. /// - /// [chunkEvents] is used to improve the quality of an [ImageProvider], and - /// should be set when [MultiFrameImageStreamCompleter] is in use inside an - /// [ImageProvider.loadImage]. Note that it will be closed by this method. - /// /// [startedLoading] & [finishedLoadingBytes] are used to indicate to /// flutter_map when it is safe to dispose a [TileProvider], and should be set /// when used inside a [TileProvider]'s context (such as directly or within @@ -262,7 +258,6 @@ class FMTCTileProvider extends TileProvider { required TileCoordinates coords, required TileLayer options, Object? key, - StreamController? chunkEvents, void Function()? startedLoading, void Function()? finishedLoadingBytes, bool requireValidImage = false, @@ -272,7 +267,6 @@ class FMTCTileProvider extends TileProvider { options: options, provider: this, key: key, - chunkEvents: chunkEvents, startedLoading: startedLoading, finishedLoadingBytes: finishedLoadingBytes, requireValidImage: requireValidImage, From 5f3a3a5e2d87f426af4e8e2a2723472fc014da62 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 16 Aug 2024 00:12:40 +0100 Subject: [PATCH 40/97] Updated CHANGELOG Improved resolving of a `urlTransformer` in `StoreDownload.startForeground` --- CHANGELOG.md | 15 ++++++++++++--- lib/src/store/download.dart | 20 +++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 708b44aa..0e6e4fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,14 +34,23 @@ Additionally, vector tiles are now supported in theory, as the internal caching/ * Replaced `FMTCTileProviderSettings.maxStoreLength` with a `maxLength` property on each store individually * Replaced `CacheBehavior` with `BrowseLoadingStrategy` * Replaced `FMTCBrowsingErrorHandler` with `BrowsingExceptionHandler`, which may now return bytes to be displayed instead of (re)throwing exception + * Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `FMTCTileProvider.urlTransformerOmitKeyValues` utility method to provide old behaviour with more customizability) - also applies to bulk downloading in `StoreDownload.startForeground` * Removed `FMTCTileProviderSettings` & absorbed properties directly into `FMTCTileProvider` + * Improvements & additions to bulk downloadable `BaseRegion`s * Added `MultiRegion`, which contains multiple other `BaseRegion`s * Improved speed (by massive amounts) and accuracy & reduced memory consumption of `CircleRegion`'s tile generation & counting algorithm -* Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `FMTCTileProvider.urlTransformerOmitKeyValues` utility method to provide old behaviour with more customizability) -* `RootExternal.export` now returns the number of exported tiles + +And here's some smaller, but still remarkable, changes: + +* Performance of the internal tile image provider has been significantly improved when fetching images from the network URL + There was a significant time loss due to attempting to handle the network request response as a stream of incoming bytes, which allowed for `chunkEvents` to be reported back to Flutter (allowing it to get progress updates on the state of the tile), but meant the bytes had to be collected and built manually. Removing this functionality allows the network requests to use more streamlined 'package:http' methods, which does not expose a stream of incoming bytes, meaning that bytes no longer have to be treated manually. This can save hundreds of milliseconds on tile loading - a significant time save of potentially up to ~50% in some cases! +* Exporting stores is now more stable, and has improved documentation + The method now works in a dedicated temporary environment and attempts to perform two different strategies to move/copy-and-delete the result to the specified directory at the end before failing. Improved documentation covers the potential pitfalls of permissions and now recommends exporting to an app directory, then using the system share functionality on some devices. It now also returns the number of exported tiles. + * Removed deprecated remnants from v9.* -* Other generic improvements + +* Other generic improvements (performance & stability) ## [9.1.2] - 2024/08/07 diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index db1c39c8..fd4c3b6c 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -104,7 +104,9 @@ class StoreDownload { /// --- /// /// For info about [urlTransformer], see [FMTCTileProvider.urlTransformer]. - /// Defaults to the identity function. + /// If unspecified, and the [region]'s [DownloadableRegion.options] is an + /// [FMTCTileProvider], will default to that tile provider's `urlTransformer` + /// if specified. Otherwise, will default to the identity function. /// /// To set additional headers, set it via [TileProvider.headers] when /// constructing the [DownloadableRegion]. @@ -122,7 +124,7 @@ class StoreDownload { int? rateLimit, Duration? maxReportInterval = const Duration(seconds: 1), bool disableRecovery = false, - String Function(String)? urlTransformer, + UrlTransformer? urlTransformer, Object instanceId = 0, }) async* { FMTCBackendAccess.internal; // Verify intialisation @@ -160,6 +162,18 @@ class StoreDownload { ); } + final UrlTransformer resolvedUrlTransformer; + if (urlTransformer != null) { + resolvedUrlTransformer = urlTransformer; + } else { + if (region.options.tileProvider + case final FMTCTileProvider tileProvider) { + resolvedUrlTransformer = tileProvider.urlTransformer; + } else { + resolvedUrlTransformer = (u) => u; + } + } + // Create download instance final instance = DownloadInstance.registerIfAvailable(instanceId); if (instance == null) { @@ -190,7 +204,7 @@ class StoreDownload { skipSeaTiles: skipSeaTiles, maxReportInterval: maxReportInterval, rateLimit: rateLimit, - urlTransformer: urlTransformer ?? (url) => url, + urlTransformer: resolvedUrlTransformer, recoveryId: recoveryId, backend: FMTCBackendAccessThreadSafe.internal, ), From 0b876440272d5f70ff6b97650629b08ad4ae5091 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 16 Aug 2024 00:57:08 +0100 Subject: [PATCH 41/97] Minor example application improvements --- .../panels/stores/components/store_tile.dart | 14 ++++++++------ .../config_view/panels/stores/stores_list.dart | 6 +++++- .../lib/src/screens/home/map_view/map_view.dart | 12 ++++++------ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart index de75791f..6cae1f02 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart @@ -140,12 +140,14 @@ class _StoreTileState extends State { aspectRatio: 1, child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: FutureBuilder( - future: widget.tileImage, - builder: (context, snapshot) { - if (snapshot.data case final data?) return data; - return const Icon(Icons.filter_none); - }, + child: RepaintBoundary( + child: FutureBuilder( + future: widget.tileImage, + builder: (context, snapshot) { + if (snapshot.data case final data?) return data; + return const Icon(Icons.filter_none); + }, + ), ), ), ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart index e1c51a35..c533611c 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart @@ -41,7 +41,11 @@ class _StoresListState extends State { store: ( stats: store.stats.all, metadata: store.metadata.read, - tileImage: store.stats.tileImage(size: 51.2, fit: BoxFit.cover), + tileImage: store.stats.tileImage( + size: 51.2, + fit: BoxFit.cover, + gaplessPlayback: true, + ), ), }; }, diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart index 1f93a9b2..6cca291d 100644 --- a/example/lib/src/screens/home/map_view/map_view.dart +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math'; import 'package:collection/collection.dart'; @@ -5,6 +6,7 @@ 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:http/io_client.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; @@ -36,19 +38,15 @@ class MapView extends StatefulWidget { bottomPaddingWrapperBuilder; final Axis layoutDirection; - static const animationDuration = Duration(milliseconds: 500); - static const animationCurve = Curves.easeInOut; - @override State createState() => _MapViewState(); } class _MapViewState extends State with TickerProviderStateMixin { + late final _httpClient = IOClient(HttpClient()..userAgent = null); late final _mapController = AnimatedMapController( vsync: this, - curve: MapView.animationCurve, - // ignore: avoid_redundant_argument_values - duration: MapView.animationDuration, + curve: Curves.easeInOut, ); final _tileLoadingDebugger = ValueNotifier({}); @@ -297,6 +295,7 @@ class _MapViewState extends State with TickerProviderStateMixin { loadingStrategy: provider.loadingStrategy, recordHitsAndMisses: false, tileLoadingInterceptor: _tileLoadingDebugger, + httpClient: _httpClient, ), tileBuilder: !provider.displayDebugOverlay ? null @@ -357,6 +356,7 @@ class _MapViewState extends State with TickerProviderStateMixin { : SystemMouseCursors.precise, child: map, ), + // TODO: Use AnimatedSwitcher for performance AnimatedPositioned( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, From 40d345f932b6736c31f642f34868c3e7eca7afde Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 19 Aug 2024 19:32:13 +0100 Subject: [PATCH 42/97] Minor bug fix in example app --- .../config_view/panels/stores/components/store_tile.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart index 6cae1f02..8b40d7b2 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart @@ -35,6 +35,12 @@ class _StoreTileState extends State { bool _toolsDeleteLoading = false; Timer? _toolsAutoHiderTimer; + @override + void dispose() { + _toolsAutoHiderTimer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { final storeName = widget.store.storeName; From 4d441a62f44c9bed3da9839dc71d429d14488a32 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 19 Aug 2024 19:50:21 +0100 Subject: [PATCH 43/97] Updated Android example app build config --- example/.metadata | 10 +++++----- example/android/.gitignore | 2 +- example/android/app/build.gradle | 26 ++++++-------------------- example/android/build.gradle | 25 +++++++++++++++++-------- example/android/gradle.properties | 5 +---- example/android/settings.gradle | 2 +- 6 files changed, 31 insertions(+), 39 deletions(-) diff --git a/example/.metadata b/example/.metadata index ddccdffc..f9c620ae 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "29babcb32a591b9e5be8c6a6075d4fe605d46ad3" + revision: "7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30" channel: "beta" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 29babcb32a591b9e5be8c6a6075d4fe605d46ad3 - base_revision: 29babcb32a591b9e5be8c6a6075d4fe605d46ad3 + create_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 + base_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 - platform: android - create_revision: 29babcb32a591b9e5be8c6a6075d4fe605d46ad3 - base_revision: 29babcb32a591b9e5be8c6a6075d4fe605d46ad3 + create_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 + base_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 # User provided section diff --git a/example/android/.gitignore b/example/android/.gitignore index 6f568019..55afd919 100644 --- a/example/android/.gitignore +++ b/example/android/.gitignore @@ -7,7 +7,7 @@ gradle-wrapper.jar GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +# See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index a7ca49f1..9f30f142 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -5,24 +5,6 @@ plugins { id "dev.flutter.flutter-gradle-plugin" } -def localProperties = new Properties() -def localPropertiesFile = rootProject.file("local.properties") -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader("UTF-8") { reader -> - localProperties.load(reader) - } -} - -def flutterVersionCode = localProperties.getProperty("flutter.versionCode") -if (flutterVersionCode == null) { - flutterVersionCode = "10" -} - -def flutterVersionName = localProperties.getProperty("flutter.versionName") -if (flutterVersionName == null) { - flutterVersionName = "10.0" -} - android { namespace = "dev.jaffaketchup.fmtc.demo" compileSdk = flutter.compileSdkVersion @@ -33,12 +15,16 @@ android { targetCompatibility = JavaVersion.VERSION_1_8 } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + defaultConfig { applicationId = "dev.jaffaketchup.fmtc.demo" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion - versionCode = flutterVersionCode.toInteger() - versionName = flutterVersionName + versionCode = flutter.versionCode + versionName = flutter.versionName } buildTypes { diff --git a/example/android/build.gradle b/example/android/build.gradle index d9ed5779..e7ca6280 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -9,22 +9,31 @@ rootProject.buildDir = "../build" subprojects { afterEvaluate { project -> - if (project.hasProperty('android')) { - project.android { - if (namespace == null) { - namespace project.group + if (project.extensions.findByName("android") != null) { + Integer pluginCompileSdk = project.android.compileSdk + if (pluginCompileSdk != null && pluginCompileSdk < 31) { + project.logger.error( + "Warning: Overriding compileSdk version in Flutter plugin: " + + project.name + + " from " + + pluginCompileSdk + + " to 31 (to work around https://issuetracker.google.com/issues/199180389)." + + "\nIf there is not a new version of " + project.name + ", consider filing an issue against " + + project.name + + " to increase their compileSdk to the latest (otherwise try updating to the latest version)." + ) + project.android { + compileSdk 31 } } } } project.buildDir = "${rootProject.buildDir}/${project.name}" -} - -subprojects { project.evaluationDependsOn(":app") } tasks.register("clean", Delete) { delete rootProject.buildDir -} \ No newline at end of file +} + diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 5f5d39d0..25971708 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,6 +1,3 @@ -org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true -android.defaults.buildfeatures.buildconfig=true -android.nonTransitiveRClass=false -android.nonFinalResIds=false diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 65da7568..aaa490a5 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.0.1' apply false + id "com.android.application" version "8.0.1" apply false id "org.jetbrains.kotlin.android" version "1.7.10" apply false } From 771186d52d35f5ac98286a345630de38d66b2446 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 19 Aug 2024 20:01:04 +0100 Subject: [PATCH 44/97] Removed unnecessary dependencies from example application --- example/android/build.gradle | 21 --------------------- example/pubspec.yaml | 16 +++++----------- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/example/android/build.gradle b/example/android/build.gradle index e7ca6280..dd592163 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -8,27 +8,6 @@ allprojects { rootProject.buildDir = "../build" subprojects { - afterEvaluate { project -> - if (project.extensions.findByName("android") != null) { - Integer pluginCompileSdk = project.android.compileSdk - if (pluginCompileSdk != null && pluginCompileSdk < 31) { - project.logger.error( - "Warning: Overriding compileSdk version in Flutter plugin: " - + project.name - + " from " - + pluginCompileSdk - + " to 31 (to work around https://issuetracker.google.com/issues/199180389)." - + "\nIf there is not a new version of " + project.name + ", consider filing an issue against " - + project.name - + " to increase their compileSdk to the latest (otherwise try updating to the latest version)." - ) - project.android { - compileSdk 31 - } - } - } - } - project.buildDir = "${rootProject.buildDir}/${project.name}" project.evaluationDependsOn(":app") } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 5d6c1bd7..645cc991 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,31 +11,25 @@ environment: dependencies: async: ^2.11.0 - auto_size_text: ^3.0.0 - badges: ^3.1.2 - better_open_file: ^3.6.5 collection: ^1.18.0 dart_earcut: ^1.1.0 - file_picker: ^8.0.0+1 + file_picker: ^8.1.2 flutter: sdk: flutter flutter_map: ^7.0.2 - flutter_map_animations: ^0.7.0 + flutter_map_animations: ^0.7.1 flutter_map_tile_caching: google_fonts: ^6.2.1 gpx: ^2.2.2 - http: ^1.2.1 + http: ^1.2.2 intl: ^0.19.0 latlong2: ^0.9.1 - osm_nominatim: ^3.0.0 path: ^1.9.0 path_provider: ^2.1.4 provider: ^6.1.2 - share_plus: ^10.0.1 - shared_preferences: ^2.2.3 + share_plus: ^10.0.2 + shared_preferences: ^2.3.2 stream_transform: ^2.1.0 - validators: ^3.0.0 - version: ^3.0.2 dependency_overrides: flutter_map_tile_caching: From d0809ae421dad414923b50bafe1e8238aad0e508 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 19 Aug 2024 21:04:22 +0100 Subject: [PATCH 45/97] Upgrade Kotlin version for example app --- example/android/settings.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/android/settings.gradle b/example/android/settings.gradle index aaa490a5..536e0594 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.0.1" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false } include ":app" From 9e72a671026c1cd403ce97164d1a01c3f1b4e6f1 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 19 Aug 2024 22:04:25 +0100 Subject: [PATCH 46/97] Updated Gradle version for example app Updated NDK version for example app Upgraded actions for workflow Enabled caching of actions for workflow --- .github/workflows/main.yml | 45 ++++++++++--------- example/android/app/build.gradle | 3 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle | 2 +- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e3ea1f2d..1a06f7f2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@master + uses: actions/checkout@v4 - name: Run Dart Package Analyser uses: axel-op/dart-package-analyzer@master id: analysis @@ -35,11 +35,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@master + uses: actions/checkout@v4 - name: Setup Flutter Environment - uses: subosito/flutter-action@main + uses: subosito/flutter-action@v2 with: channel: "beta" + cache: true - name: Get Package Dependencies run: flutter pub get - name: Get Example Dependencies @@ -56,11 +57,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@master + uses: actions/checkout@v4 - name: Setup Flutter Environment - uses: subosito/flutter-action@main + uses: subosito/flutter-action@v2 with: channel: "beta" + cache: true - name: Get Dependencies run: flutter pub get - name: Install ObjectBox Libs For Testing @@ -77,20 +79,22 @@ jobs: working-directory: ./example steps: - name: Checkout Repository - uses: actions/checkout@master - - name: Setup Java 17 Environment - uses: actions/setup-java@v3 + uses: actions/checkout@v4 + - name: Setup Java 21 Environment + uses: actions/setup-java@v4 with: distribution: "temurin" - java-version: "17" + java-version: "21" + cache: 'gradle' - name: Setup Flutter Environment - uses: subosito/flutter-action@main + uses: subosito/flutter-action@v2 with: channel: "beta" + cache: true - name: Build run: flutter build apk --obfuscate --split-debug-info=./symbols - name: Upload Artifact - uses: actions/upload-artifact@v3.1.2 + uses: actions/upload-artifact@v4 with: name: android-demo path: example/build/app/outputs/apk/release @@ -105,18 +109,19 @@ jobs: working-directory: ./example steps: - name: Checkout Repository - uses: actions/checkout@master + uses: actions/checkout@v4 - name: Setup Flutter Environment - uses: subosito/flutter-action@main + uses: subosito/flutter-action@v2 with: channel: "beta" + cache: true - name: Build run: flutter build windows --obfuscate --split-debug-info=./symbols - name: Create Installer run: iscc "windowsApplicationInstallerSetup.iss" working-directory: . - name: Upload Artifact - uses: actions/upload-artifact@v3.1.2 + uses: actions/upload-artifact@v4 with: name: windows-demo path: windowsTemp/WindowsApplication.exe @@ -131,9 +136,9 @@ jobs: working-directory: ./tile_server steps: - name: Checkout Repository - uses: actions/checkout@master + uses: actions/checkout@v4 - name: Setup Dart Environment - uses: dart-lang/setup-dart@v1.6.2 + uses: dart-lang/setup-dart@v1 - name: Get Dependencies run: dart pub get - name: Get Dart Dependencies @@ -143,7 +148,7 @@ jobs: - name: Compile run: dart compile exe bin/tile_server.dart - name: Upload Artifact - uses: actions/upload-artifact@v4.3.1 + uses: actions/upload-artifact@v4 with: name: windows-ts path: tile_server/bin/tile_server.exe @@ -158,9 +163,9 @@ jobs: working-directory: ./tile_server steps: - name: Checkout Repository - uses: actions/checkout@master + uses: actions/checkout@v4 - name: Setup Dart Environment - uses: dart-lang/setup-dart@v1.6.2 + uses: dart-lang/setup-dart@v1 - name: Get Dependencies run: dart pub get - name: Run Pre-Compile Generator @@ -168,7 +173,7 @@ jobs: - name: Compile run: dart compile exe bin/tile_server.dart - name: Upload Artifact - uses: actions/upload-artifact@v3.1.2 + uses: actions/upload-artifact@v4 with: name: linux-ts path: tile_server/bin/tile_server.exe diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 9f30f142..81489f7c 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -8,7 +8,8 @@ plugins { android { namespace = "dev.jaffaketchup.fmtc.demo" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + // ndkVersion = flutter.ndkVersion + ndkVersion = "26.1.10909125" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 8bc9958a..3c85cfe0 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 536e0594..d3bb611e 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.0.1" apply false + id "com.android.application" version '8.5.2' apply false id "org.jetbrains.kotlin.android" version "1.8.22" apply false } From 05c0f0c7dca2b2064a28cfe9c25a0789fcffa973 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 19 Aug 2024 22:18:19 +0100 Subject: [PATCH 47/97] Removed broken workaround from Android example app config --- example/android/app/build.gradle | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 81489f7c..8e644b93 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -38,13 +38,3 @@ android { flutter { source = "../.." } - -configurations.all { - resolutionStrategy { - eachDependency { - if ((requested.group == "org.jetbrains.kotlin") && (requested.name.startsWith("kotlin-stdlib"))) { - useVersion("1.7.10") - } - } - } -} \ No newline at end of file From e284f492c339bef40d005e5ee830715aa45b7892 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 19 Aug 2024 23:48:46 +0100 Subject: [PATCH 48/97] Fixed bug where `deleteStore` never completed (but was successful) --- .../objectbox/backend/internal_workers/standard/worker.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index d870d60d..cbe8229f 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -415,6 +415,8 @@ Future _worker( storesQuery.remove(); + sendRes(id: cmd.id); + storesQuery.close(); tilesQuery.close(); case _CmdType.tileExists: From bd57c2b00aa706a53a9c68b8c69a779c608d422a Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 21 Aug 2024 01:33:19 +0100 Subject: [PATCH 49/97] Added `FMTCTileProvider.fakeNetworkDisconnect` for testing (and reflected in example app) Fixed bug in internal `_sharedWriteSingleTile` where `writeAllNotIn` was not `null` Fixed bug when calculating `tileExistsInUnspecifiedStoresOnly` in `_internalGetBytes` (now `tileRetrievableFromOtherStoresAsFallback`) Renamed `TileLoadingInterceptorResult.tileExistsInUnspecifiedStoresOnly` to `.tileRetrievedFromOtherStoresAsFallback` & added `resultPath` requirement for truthy Renamed `TileLoadingInterceptorResultPath.noFetch` & `.fetched` to `.cacheAsFallback` & `.fetchedFromNetwork` respectively Added `FMTCTileProvider.otherStoresStrategy` & `.useOtherStoresAsFallbackOnly` management to example app Added storage of some settings to shared preferences in example app Minor internal improvements Minor documentation improvements --- .../home/config_view/forms/side/side.dart | 28 +-- .../home/config_view/panels/map/map.dart | 33 +++- .../store_read_write_behaviour_selector.dart | 63 +++--- .../{ => store_tiles}/root_tile.dart | 2 +- .../{ => store_tiles}/store_tile.dart | 12 +- .../store_tiles/unspecified_tile.dart | 93 +++++++++ .../panels/stores/stores_list.dart | 17 +- .../components/debugging_tile_builder.dart | 186 ------------------ .../debugging_tile_builder.dart | 67 +++++++ .../debugging_tile_builder/info_display.dart | 93 +++++++++ .../result_dialogs.dart | 77 ++++++++ .../src/screens/home/map_view/map_view.dart | 16 +- .../screens/store_editor/store_editor.dart | 4 +- .../shared/components/build_attribution.dart | 8 +- .../src/shared/components/url_selector.dart | 25 +-- .../src/shared/misc/shared_preferences.dart | 6 + .../src/shared/state/general_provider.dart | 87 +++++++- lib/flutter_map_tile_caching.dart | 8 +- .../backend/internal_workers/shared.dart | 22 +-- .../bulk_download/external/tile_event.dart | 4 +- lib/src/bulk_download/internal/manager.dart | 2 +- .../internal/rate_limited_stream.dart | 1 - lib/src/bulk_download/internal/thread.dart | 4 +- .../image_provider/image_provider.dart | 6 +- .../image_provider/internal_get_bytes.dart | 43 ++-- .../tile_loading_interceptor/result.dart | 25 ++- .../tile_loading_interceptor/result_path.dart | 4 +- .../tile_provider/tile_provider.dart | 57 ++++-- lib/src/providers/tile_provider/typedefs.dart | 3 +- lib/src/regions/shapes/line.dart | 2 +- lib/src/store/download.dart | 5 +- lib/src/store/store.dart | 5 +- 32 files changed, 648 insertions(+), 360 deletions(-) rename example/lib/src/screens/home/config_view/panels/stores/components/{ => store_tiles}/root_tile.dart (97%) rename example/lib/src/screens/home/config_view/panels/stores/components/{ => store_tiles}/store_tile.dart (97%) create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart delete mode 100644 example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart create mode 100644 example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart create mode 100644 example/lib/src/screens/home/map_view/components/debugging_tile_builder/info_display.dart create mode 100644 example/lib/src/screens/home/map_view/components/debugging_tile_builder/result_dialogs.dart diff --git a/example/lib/src/screens/home/config_view/forms/side/side.dart b/example/lib/src/screens/home/config_view/forms/side/side.dart index 21407213..76045432 100644 --- a/example/lib/src/screens/home/config_view/forms/side/side.dart +++ b/example/lib/src/screens/home/config_view/forms/side/side.dart @@ -39,35 +39,9 @@ class _ContentPanels extends StatelessWidget { Widget build(BuildContext context) => Padding( padding: const EdgeInsets.only(right: 16, top: 16), child: SizedBox( - width: (constraints.maxWidth / 3).clamp(430, 530), + width: (constraints.maxWidth / 3).clamp(515, 560), child: Column( children: [ - DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(99), - color: Theme.of(context).colorScheme.surface, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: Row( - children: [ - Text( - 'Stores & Config', - style: Theme.of(context).textTheme.titleLarge, - ), - const Spacer(), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.help_outline), - ), - ], - ), - ), - ), - const SizedBox(height: 16), Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), diff --git a/example/lib/src/screens/home/config_view/panels/map/map.dart b/example/lib/src/screens/home/config_view/panels/map/map.dart index fb206936..b697a066 100644 --- a/example/lib/src/screens/home/config_view/panels/map/map.dart +++ b/example/lib/src/screens/home/config_view/panels/map/map.dart @@ -80,8 +80,8 @@ class _ConfigPanelMapState extends State { builder: (context, displayDebugOverlay, _) => Row( children: [ const SizedBox(width: 8), - const Text('Display debug/info tile overlay'), - const Spacer(), + const Expanded(child: Text('Display debug/info tile overlay')), + const SizedBox(width: 12), Switch.adaptive( value: displayDebugOverlay, onChanged: (value) => context @@ -97,6 +97,35 @@ class _ConfigPanelMapState extends State { ], ), ), + Selector( + selector: (context, provider) => provider.fakeNetworkDisconnect, + builder: (context, fakeNetworkDisconnect, _) => Row( + children: [ + const SizedBox(width: 8), + const Expanded( + child: Text('Fake network disconnect (when FMTC in use)'), + ), + const SizedBox(width: 12), + Switch.adaptive( + value: fakeNetworkDisconnect, + onChanged: (value) => context + .read() + .fakeNetworkDisconnect = value, + thumbIcon: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? const Icon(Icons.cloud_off) + : const Icon(Icons.cloud), + ), + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? Colors.orange + : null, + ), + ), + const SizedBox(width: 8), + ], + ), + ), ], ); } diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart index 2b95a1ae..31893732 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart @@ -10,44 +10,51 @@ class StoreReadWriteBehaviourSelector extends StatelessWidget { super.key, required this.storeName, required this.enabled, + this.inheritable = true, }); final String storeName; final bool enabled; + final bool inheritable; @override Widget build(BuildContext context) => Selector( selector: (context, provider) => provider.currentStores[storeName], - builder: (context, currentBehaviour, child) => - Selector( - selector: (context, provider) => - provider.inheritableBrowseStoreStrategy, - builder: (context, inheritableBehaviour, _) => Row( + builder: (context, currentBehaviour, child) { + final inheritableBehaviour = inheritable + ? context.select( + (provider) => provider.inheritableBrowseStoreStrategy, + ) + : null; + + return Row( mainAxisSize: MainAxisSize.min, children: [ - Checkbox.adaptive( - value: - currentBehaviour == InternalBrowseStoreStrategy.inherit || - currentBehaviour == null, - onChanged: enabled - ? (v) { - final provider = context.read(); + if (inheritable) ...[ + Checkbox.adaptive( + value: + currentBehaviour == InternalBrowseStoreStrategy.inherit || + currentBehaviour == null, + onChanged: enabled + ? (v) { + final provider = context.read(); - provider - ..currentStores[storeName] = v! - ? InternalBrowseStoreStrategy.inherit - : InternalBrowseStoreStrategy - .fromBrowseStoreStrategy( - provider.inheritableBrowseStoreStrategy, - ) - ..changedCurrentStores(); - } - : null, - materialTapTargetSize: MaterialTapTargetSize.padded, - visualDensity: VisualDensity.comfortable, - ), - const VerticalDivider(width: 2), + provider + ..currentStores[storeName] = v! + ? InternalBrowseStoreStrategy.inherit + : InternalBrowseStoreStrategy + .fromBrowseStoreStrategy( + provider.inheritableBrowseStoreStrategy, + ) + ..changedCurrentStores(); + } + : null, + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, + ), + const VerticalDivider(width: 2), + ], ...BrowseStoreStrategy.values.map( (e) => _StoreReadWriteBehaviourSelectorCheckbox( storeName: storeName, @@ -60,8 +67,8 @@ class StoreReadWriteBehaviourSelector extends StatelessWidget { ), ), ], - ), - ), + ); + }, ); } diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/root_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/root_tile.dart similarity index 97% rename from example/lib/src/screens/home/config_view/panels/stores/components/root_tile.dart rename to example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/root_tile.dart index c3d0f1b4..172300fa 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/root_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/root_tile.dart @@ -30,7 +30,7 @@ class _RootTileState extends State { ), leading: const SizedBox.square( dimension: 48, - child: Icon(Icons.language, size: 28), + child: Icon(Icons.storage_rounded, size: 28), ), trailing: Row( mainAxisSize: MainAxisSize.min, diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart similarity index 97% rename from example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart rename to example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart index 8b40d7b2..842e87ee 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart @@ -4,12 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../../../../shared/misc/exts/size_formatter.dart'; -import '../../../../../../shared/misc/store_metadata_keys.dart'; -import '../../../../../../shared/state/general_provider.dart'; -import '../../../../../store_editor/store_editor.dart'; -import '../state/export_selection_provider.dart'; -import 'store_read_write_behaviour_selector.dart'; +import '../../../../../../../shared/misc/exts/size_formatter.dart'; +import '../../../../../../../shared/misc/store_metadata_keys.dart'; +import '../../../../../../../shared/state/general_provider.dart'; +import '../../../../../../store_editor/store_editor.dart'; +import '../../state/export_selection_provider.dart'; +import '../store_read_write_behaviour_selector.dart'; class StoreTile extends StatefulWidget { const StoreTile({ diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart new file mode 100644 index 00000000..c3e15f8c --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; +import '../../../../../../../shared/state/general_provider.dart'; +import '../store_read_write_behaviour_selector.dart'; + +class UnspecifiedTile extends StatefulWidget { + const UnspecifiedTile({ + super.key, + }); + + @override + State createState() => _UnspecifiedTileState(); +} + +class _UnspecifiedTileState extends State { + @override + Widget build(BuildContext context) { + final isAllUnselectedDisabled = context + .select( + (provider) => provider.currentStores['(unspecified)'], + ) + ?.toBrowseStoreStrategy() == + null; + + return RepaintBoundary( + child: Material( + color: Colors.transparent, + child: ListTile( + title: const Text( + 'All URL matching unselected', + maxLines: 2, + overflow: TextOverflow.fade, + ), + leading: const SizedBox.square( + dimension: 48, + child: Icon(Icons.unpublished, size: 28), + ), + trailing: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceDim, + borderRadius: BorderRadius.circular(99), + ), + child: Tooltip( + message: 'Use as fallback only', + child: Row( + children: [ + const Icon(Icons.last_page), + const SizedBox(width: 4), + Switch.adaptive( + value: !isAllUnselectedDisabled && + context.select( + (provider) => + provider.useUnspecifiedAsFallbackOnly, + ), + onChanged: isAllUnselectedDisabled + ? null + : (v) { + context + .read() + .useUnspecifiedAsFallbackOnly = v; + }, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + const VerticalDivider(width: 2), + const StoreReadWriteBehaviourSelector( + storeName: '(unspecified)', + enabled: true, + inheritable: false, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart index c533611c..f4b939d3 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart @@ -7,8 +7,9 @@ import 'components/column_headers_and_inheritable_settings.dart'; import 'components/export_stores/button.dart'; import 'components/new_store_button.dart'; import 'components/no_stores.dart'; -import 'components/root_tile.dart'; -import 'components/store_tile.dart'; +import 'components/store_tiles/root_tile.dart'; +import 'components/store_tiles/store_tile.dart'; +import 'components/store_tiles/unspecified_tile.dart'; import 'state/export_selection_provider.dart'; class StoresList extends StatefulWidget { @@ -69,19 +70,22 @@ class _StoresListState extends State { if (stores.isEmpty) return const NoStores(); return SliverList.separated( - itemCount: stores.length + 3, + itemCount: stores.length + 4, itemBuilder: (context, index) { if (index == 0) { return const ColumnHeadersAndInheritableSettings(); } if (index - 1 == stores.length) { + return const UnspecifiedTile(); + } + if (index - 2 == stores.length) { return RootTile( length: _rootLength, size: _rootSize, realSizeAdditional: _rootRealSizeAdditional, ); } - if (index - 2 == stores.length) { + if (index - 3 == stores.length) { return Builder( builder: (context) => context.select( @@ -105,9 +109,10 @@ class _StoresListState extends State { tileImage: tileImage, ); }, - separatorBuilder: (context, index) => index - 2 == stores.length - 1 + separatorBuilder: (context, index) => index - 3 == stores.length - 1 ? const Divider() - : index - 1 == stores.length - 1 + : index - 2 == stores.length - 1 || + index - 1 == stores.length - 1 ? const Divider(height: 8, indent: 12, endIndent: 12) : const SizedBox.shrink(), ); diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart b/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart deleted file mode 100644 index d8f4e5df..00000000 --- a/example/lib/src/screens/home/map_view/components/debugging_tile_builder.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -class DebuggingTileBuilder extends StatelessWidget { - const DebuggingTileBuilder({ - super.key, - required this.tileWidget, - required this.tile, - required this.tileLoadingDebugger, - required this.usingFMTC, - }); - - final Widget tileWidget; - final TileImage tile; - final ValueNotifier tileLoadingDebugger; - final bool usingFMTC; - - @override - Widget build(BuildContext context) => Stack( - fit: StackFit.expand, - alignment: Alignment.center, - children: [ - DecoratedBox( - decoration: BoxDecoration( - border: Border.all( - color: Colors.black.withOpacity(0.8), - width: 3, - ), - color: Colors.white.withOpacity(0.5), - ), - position: DecorationPosition.foreground, - child: tileWidget, - ), - if (!usingFMTC) - const OverflowBox( - child: Padding( - padding: EdgeInsets.all(6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.disabled_by_default_rounded, - size: 32, - ), - SizedBox(height: 6), - Text('FMTC not in use'), - ], - ), - ), - ) - else - ValueListenableBuilder( - valueListenable: tileLoadingDebugger, - builder: (context, value, _) { - final info = value[tile.coordinates]; - - if (info == null) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } - - return Padding( - padding: const EdgeInsets.all(8), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'x${tile.coordinates.x} y${tile.coordinates.y} ' - 'z${tile.coordinates.z}', - style: const TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - if (info.error case final error?) - Text( - error is FMTCBrowsingError - ? '`${error.type.name}`' - : 'Unknown error (${error.runtimeType})', - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.red, - fontStyle: FontStyle.italic, - ), - ), - if (info.resultPath case final result?) ...[ - Text( - '`${result.name}` in ${tile.loadFinishedAt == null || tile.loadStarted == null ? '...' : tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms', - textAlign: TextAlign.center, - ), - Text( - '(${info.cacheFetchDuration.inMilliseconds} ms cache${info.networkFetchDuration == null ? ')' : ' | ${info.networkFetchDuration!.inMilliseconds} ms network)'}\n', - textAlign: TextAlign.center, - ), - if (info.existingStores case final existingStores?) - Text( - "Existed in: '${existingStores.join("', '")}'", - textAlign: TextAlign.center, - ) - else - const Text( - 'New tile', - textAlign: TextAlign.center, - ), - if (info.storesWriteResult case final writeResult?) - FutureBuilder( - future: writeResult, - builder: (context, snapshot) { - if (snapshot.data == null) { - return const Text('Caching tile...'); - } - return TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - visualDensity: VisualDensity.compact, - minimumSize: Size.zero, - ), - onPressed: () { - showDialog( - context: context, - builder: (context) => - _TileWriteResultsDialog( - results: snapshot.data!, - ), - ); - }, - child: const Text('View write result'), - ); - }, - ) - else - const Text('No write necessary'), - ], - ], - ), - ), - ); - }, - ), - ], - ); -} - -class _TileWriteResultsDialog extends StatelessWidget { - const _TileWriteResultsDialog({required this.results}); - - final Map results; - - @override - Widget build(BuildContext context) { - final newlyWritten = - results.entries.where((e) => e.value).map((e) => e.key); - final updated = results.entries.where((e) => !e.value).map((e) => e.key); - - return AlertDialog.adaptive( - title: const Text('Tile Write Results'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Newly written to: ', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Text(newlyWritten.isEmpty ? 'None' : newlyWritten.join('\n')), - const Text( - '\nUpdated in: ', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Text(updated.isEmpty ? 'None' : updated.join('\n')), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ); - } -} diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart b/example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart new file mode 100644 index 00000000..47433fa4 --- /dev/null +++ b/example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +part 'info_display.dart'; +part 'result_dialogs.dart'; + +class DebuggingTileBuilder extends StatelessWidget { + const DebuggingTileBuilder({ + super.key, + required this.tileWidget, + required this.tile, + required this.tileLoadingDebugger, + required this.usingFMTC, + }); + + final Widget tileWidget; + final TileImage tile; + final ValueNotifier tileLoadingDebugger; + final bool usingFMTC; + + @override + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Colors.black.withOpacity(0.8), + width: 2, + ), + color: Colors.white.withOpacity(0.5), + ), + position: DecorationPosition.foreground, + child: tileWidget, + ), + if (!usingFMTC) + const OverflowBox( + child: Padding( + padding: EdgeInsets.all(6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.disabled_by_default_rounded, size: 32), + SizedBox(height: 6), + Text('FMTC not in use'), + ], + ), + ), + ) + else + ValueListenableBuilder( + valueListenable: tileLoadingDebugger, + builder: (context, value, _) { + if (value[tile.coordinates] case final info?) { + return _ResultDisplay(tile: tile, fmtcResult: info); + } + + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + }, + ), + ], + ); +} diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder/info_display.dart b/example/lib/src/screens/home/map_view/components/debugging_tile_builder/info_display.dart new file mode 100644 index 00000000..b131b201 --- /dev/null +++ b/example/lib/src/screens/home/map_view/components/debugging_tile_builder/info_display.dart @@ -0,0 +1,93 @@ +part of 'debugging_tile_builder.dart'; + +class _ResultDisplay extends StatelessWidget { + const _ResultDisplay({ + required this.tile, + required this.fmtcResult, + }); + + final TileImage tile; + final TileLoadingInterceptorResult fmtcResult; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(8), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'x${tile.coordinates.x} y${tile.coordinates.y} ' + 'z${tile.coordinates.z}', + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + if (fmtcResult.error case final error?) + Text( + error is FMTCBrowsingError + ? '`${error.type.name}`' + : 'Unknown error (${error.runtimeType})', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.red, + fontStyle: FontStyle.italic, + ), + ), + if (fmtcResult.resultPath case final result?) ...[ + Text( + '`${result.name}` in ${tile.loadFinishedAt == null || tile.loadStarted == null ? '...' : tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms', + textAlign: TextAlign.center, + ), + Text( + '(${fmtcResult.cacheFetchDuration.inMilliseconds} ms cache${fmtcResult.networkFetchDuration == null ? ')' : ' | ${fmtcResult.networkFetchDuration!.inMilliseconds} ms network)'}\n', + textAlign: TextAlign.center, + ), + Row( + children: [ + IconButton.filledTonal( + onPressed: fmtcResult.existingStores != null + ? () { + showDialog( + context: context, + builder: (context) => _TileReadResultsDialog( + results: fmtcResult.existingStores!, + trfosaf: fmtcResult + .tileRetrievedFromOtherStoresAsFallback, + ), + ); + } + : null, + icon: fmtcResult.existingStores != null + ? const Icon(Icons.visibility) + : const Icon(Icons.visibility_off), + tooltip: 'View cache exists result', + ), + const SizedBox(width: 8), + FutureBuilder( + future: fmtcResult.storesWriteResult, + builder: (context, snapshot) => IconButton.filledTonal( + onPressed: snapshot.data != null + ? () { + showDialog( + context: context, + builder: (context) => _TileWriteResultsDialog( + results: snapshot.data!, + ), + ); + } + : null, + icon: snapshot.data != null + ? const Icon(Icons.edit) + : const Icon(Icons.edit_off), + tooltip: 'View write result', + ), + ), + ], + ), + ], + ], + ), + ), + ); +} diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder/result_dialogs.dart b/example/lib/src/screens/home/map_view/components/debugging_tile_builder/result_dialogs.dart new file mode 100644 index 00000000..12f75ba2 --- /dev/null +++ b/example/lib/src/screens/home/map_view/components/debugging_tile_builder/result_dialogs.dart @@ -0,0 +1,77 @@ +part of 'debugging_tile_builder.dart'; + +class _TileReadResultsDialog extends StatelessWidget { + const _TileReadResultsDialog({ + required this.results, + required this.trfosaf, + }); + + final List results; + final bool trfosaf; + + @override + Widget build(BuildContext context) => AlertDialog.adaptive( + title: const Text('Tile Cache Exists Results'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Exists in:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(results.join('\n')), + Text( + '\nThis does not imply that the tile was actually used/retrieved ' + 'from these stores.\n' + '`tileRetrievedFromOtherStoresAsFallback`: $trfosaf', + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); +} + +class _TileWriteResultsDialog extends StatelessWidget { + const _TileWriteResultsDialog({required this.results}); + + final Map results; + + @override + Widget build(BuildContext context) { + final newlyWritten = + results.entries.where((e) => e.value).map((e) => e.key); + final updated = results.entries.where((e) => !e.value).map((e) => e.key); + + return AlertDialog.adaptive( + title: const Text('Tile Write Results'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Newly written to: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(newlyWritten.isEmpty ? 'None' : newlyWritten.join('\n')), + const Text( + '\nUpdated in: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(updated.isEmpty ? 'None' : updated.join('\n')), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + } +} diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/home/map_view/map_view.dart index 6cca291d..a643796a 100644 --- a/example/lib/src/screens/home/map_view/map_view.dart +++ b/example/lib/src/screens/home/map_view/map_view.dart @@ -13,7 +13,7 @@ import 'package:provider/provider.dart'; import '../../../shared/misc/shared_preferences.dart'; import '../../../shared/misc/store_metadata_keys.dart'; import '../../../shared/state/general_provider.dart'; -import 'components/debugging_tile_builder.dart'; +import 'components/debugging_tile_builder/debugging_tile_builder.dart'; import 'components/region_selection/crosshairs.dart'; import 'components/region_selection/custom_polygon_snapping_indicator.dart'; import 'components/region_selection/region_shape.dart'; @@ -280,6 +280,9 @@ class _MapViewState extends State with TickerProviderStateMixin { ], ); + final otherStoresStrategy = provider.currentStores['(unspecified)'] + ?.toBrowseStoreStrategy(); + final map = FlutterMap( mapController: _mapController.mapController, options: mapOptions, @@ -288,14 +291,20 @@ class _MapViewState extends State with TickerProviderStateMixin { urlTemplate: urlTemplate, userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', maxNativeZoom: 20, - tileProvider: compiledStoreNames.isEmpty + tileProvider: compiledStoreNames.isEmpty && + otherStoresStrategy == null ? NetworkTileProvider() : FMTCTileProvider.multipleStores( storeNames: compiledStoreNames, + otherStoresStrategy: otherStoresStrategy, loadingStrategy: provider.loadingStrategy, + useOtherStoresAsFallbackOnly: + provider.useUnspecifiedAsFallbackOnly, recordHitsAndMisses: false, tileLoadingInterceptor: _tileLoadingDebugger, httpClient: _httpClient, + // ignore: invalid_use_of_visible_for_testing_member + fakeNetworkDisconnect: provider.fakeNetworkDisconnect, ), tileBuilder: !provider.displayDebugOverlay ? null @@ -303,7 +312,8 @@ class _MapViewState extends State with TickerProviderStateMixin { tileLoadingDebugger: _tileLoadingDebugger, tileWidget: tileWidget, tile: tile, - usingFMTC: compiledStoreNames.isNotEmpty, + usingFMTC: compiledStoreNames.isNotEmpty || + otherStoresStrategy != null, ), ), if (widget.mode == MapViewMode.regionSelect) ...[ diff --git a/example/lib/src/screens/store_editor/store_editor.dart b/example/lib/src/screens/store_editor/store_editor.dart index cbaa3dd8..897208ee 100644 --- a/example/lib/src/screens/store_editor/store_editor.dart +++ b/example/lib/src/screens/store_editor/store_editor.dart @@ -77,7 +77,9 @@ class _StoreEditorPopupState extends State { : snapshot.data!.contains(input) && input != existingStoreName ? 'Store already exists' - : input == '(default)' || input == '(custom)' + : input == '(default)' || + input == '(custom)' || + input == '(unspecified)' ? 'Name reserved (in example app)' : null, onSaved: (input) => newName = input, diff --git a/example/lib/src/shared/components/build_attribution.dart b/example/lib/src/shared/components/build_attribution.dart index 02f3ef1f..19e7c56b 100644 --- a/example/lib/src/shared/components/build_attribution.dart +++ b/example/lib/src/shared/components/build_attribution.dart @@ -5,19 +5,15 @@ class StandardAttribution extends StatelessWidget { const StandardAttribution({ super.key, required this.urlTemplate, - this.alignment = AttributionAlignment.bottomRight, }); final String urlTemplate; - final AttributionAlignment alignment; @override Widget build(BuildContext context) => RichAttributionWidget( - alignment: alignment, + alignment: AttributionAlignment.bottomLeft, popupInitialDisplayDuration: const Duration(seconds: 3), - popupBorderRadius: alignment == AttributionAlignment.bottomRight - ? null - : BorderRadius.circular(10), + popupBorderRadius: BorderRadius.circular(16), attributions: [ TextSourceAttribution(Uri.parse(urlTemplate).host), const TextSourceAttribution( diff --git a/example/lib/src/shared/components/url_selector.dart b/example/lib/src/shared/components/url_selector.dart index 1d4f5fa0..af8cb024 100644 --- a/example/lib/src/shared/components/url_selector.dart +++ b/example/lib/src/shared/components/url_selector.dart @@ -11,14 +11,14 @@ import '../misc/store_metadata_keys.dart'; class URLSelector extends StatefulWidget { const URLSelector({ super.key, - this.initialValue, + required this.initialValue, this.onSelected, this.helperText, this.onFocus, this.onUnfocus, }); - final String? initialValue; + final String initialValue; final void Function(String)? onSelected; final String? helperText; final void Function()? onFocus; @@ -29,14 +29,13 @@ class URLSelector extends StatefulWidget { } class _URLSelectorState extends State { - static const _sharedPrefsNonStoreUrlsKey = 'customNonStoreUrls'; static const _defaultUrlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; late final urlTextController = TextEditingControllerWithMatcherStylizer( TileProvider.templatePlaceholderElement, const TextStyle(fontStyle: FontStyle.italic), - initialValue: widget.initialValue ?? _defaultUrlTemplate, + initialValue: widget.initialValue, ); final selectableEntriesManualRefreshStream = StreamController(); @@ -113,8 +112,7 @@ class _URLSelectorState extends State { filled: true, helperMaxLines: 2, ), - initialSelection: - widget.initialValue ?? _defaultUrlTemplate, + initialSelection: widget.initialValue, // Bug in `DropdownMenu` means this cannot be `true` // enableFilter: true, dropdownMenuEntries: _constructMenuEntries(snapshot), @@ -146,8 +144,9 @@ class _URLSelectorState extends State { void _onSelected(String? v) { if (v == null) { sharedPrefs.setStringList( - _sharedPrefsNonStoreUrlsKey, - (sharedPrefs.getStringList(_sharedPrefsNonStoreUrlsKey) ?? []) + SharedPrefsKeys.customNonStoreUrls.name, + (sharedPrefs.getStringList(SharedPrefsKeys.customNonStoreUrls.name) ?? + []) ..add(urlTextController.text), ); @@ -172,7 +171,8 @@ class _URLSelectorState extends State { ) ..add((storeName: '(default)', urlTemplate: _defaultUrlTemplate)) ..addAll( - (sharedPrefs.getStringList(_sharedPrefsNonStoreUrlsKey) ?? []) + (sharedPrefs.getStringList(SharedPrefsKeys.customNonStoreUrls.name) ?? + []) .map((url) => (storeName: '(custom)', urlTemplate: url)), ); @@ -210,9 +210,10 @@ class _URLSelectorState extends State { ? IconButton( onPressed: () { sharedPrefs.setStringList( - _sharedPrefsNonStoreUrlsKey, - (sharedPrefs - .getStringList(_sharedPrefsNonStoreUrlsKey) ?? + SharedPrefsKeys.customNonStoreUrls.name, + (sharedPrefs.getStringList( + SharedPrefsKeys.customNonStoreUrls.name, + ) ?? []) ..remove(e.key), ); diff --git a/example/lib/src/shared/misc/shared_preferences.dart b/example/lib/src/shared/misc/shared_preferences.dart index 904d46ef..85048d7b 100644 --- a/example/lib/src/shared/misc/shared_preferences.dart +++ b/example/lib/src/shared/misc/shared_preferences.dart @@ -6,4 +6,10 @@ enum SharedPrefsKeys { mapLocationLat, mapLocationLng, mapLocationZoom, + customNonStoreUrls, + urlTemplate, + inheritableBrowseStoreStrategy, + browseLoadingStrategy, + displayDebugOverlay, + fakeNetworkDisconnect, } diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart index 53479544..0903d777 100644 --- a/example/lib/src/shared/state/general_provider.dart +++ b/example/lib/src/shared/state/general_provider.dart @@ -2,38 +2,115 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import '../misc/internal_store_read_write_behaviour.dart'; +import '../misc/shared_preferences.dart'; class GeneralProvider extends ChangeNotifier { - BrowseStoreStrategy? _inheritableBrowseStoreStrategy = - BrowseStoreStrategy.readUpdateCreate; + BrowseStoreStrategy? _inheritableBrowseStoreStrategy = () { + final storedStrategyName = sharedPrefs + .getString(SharedPrefsKeys.inheritableBrowseStoreStrategy.name); + if (storedStrategyName case final storedStrategyName?) { + if (storedStrategyName == '') return null; + return BrowseStoreStrategy.values.byName(storedStrategyName); + } + sharedPrefs.setString( + SharedPrefsKeys.inheritableBrowseStoreStrategy.name, + BrowseStoreStrategy.readUpdateCreate.name, + ); + return BrowseStoreStrategy.readUpdateCreate; + }(); BrowseStoreStrategy? get inheritableBrowseStoreStrategy => _inheritableBrowseStoreStrategy; set inheritableBrowseStoreStrategy(BrowseStoreStrategy? newStoreStrategy) { _inheritableBrowseStoreStrategy = newStoreStrategy; + sharedPrefs.setString( + SharedPrefsKeys.inheritableBrowseStoreStrategy.name, + newStoreStrategy?.name ?? '', + ); notifyListeners(); } final Map currentStores = {}; void changedCurrentStores() => notifyListeners(); - String _urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + String _urlTemplate = + sharedPrefs.getString(SharedPrefsKeys.urlTemplate.name) ?? + (() { + const defaultUrlTemplate = + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + sharedPrefs.setString( + SharedPrefsKeys.urlTemplate.name, + defaultUrlTemplate, + ); + return defaultUrlTemplate; + }()); String get urlTemplate => _urlTemplate; set urlTemplate(String newUrlTemplate) { _urlTemplate = newUrlTemplate; + sharedPrefs.setString(SharedPrefsKeys.urlTemplate.name, newUrlTemplate); notifyListeners(); } - BrowseLoadingStrategy _loadingStrategy = BrowseLoadingStrategy.cacheFirst; + BrowseLoadingStrategy _loadingStrategy = () { + final storedStrategyName = + sharedPrefs.getString(SharedPrefsKeys.browseLoadingStrategy.name); + if (storedStrategyName case final storedStrategyName?) { + return BrowseLoadingStrategy.values.byName(storedStrategyName); + } + sharedPrefs.setString( + SharedPrefsKeys.browseLoadingStrategy.name, + BrowseLoadingStrategy.cacheFirst.name, + ); + return BrowseLoadingStrategy.cacheFirst; + }(); BrowseLoadingStrategy get loadingStrategy => _loadingStrategy; set loadingStrategy(BrowseLoadingStrategy newLoadingStrategy) { _loadingStrategy = newLoadingStrategy; + sharedPrefs.setString( + SharedPrefsKeys.browseLoadingStrategy.name, + newLoadingStrategy.name, + ); notifyListeners(); } - bool _displayDebugOverlay = true; + bool _displayDebugOverlay = + sharedPrefs.getBool(SharedPrefsKeys.displayDebugOverlay.name) ?? + (() { + sharedPrefs.setBool(SharedPrefsKeys.displayDebugOverlay.name, true); + return true; + }()); bool get displayDebugOverlay => _displayDebugOverlay; set displayDebugOverlay(bool newDisplayDebugOverlay) { _displayDebugOverlay = newDisplayDebugOverlay; + sharedPrefs.setBool( + SharedPrefsKeys.displayDebugOverlay.name, + newDisplayDebugOverlay, + ); + notifyListeners(); + } + + bool _fakeNetworkDisconnect = + sharedPrefs.getBool(SharedPrefsKeys.fakeNetworkDisconnect.name) ?? + (() { + sharedPrefs.setBool( + SharedPrefsKeys.fakeNetworkDisconnect.name, + false, + ); + return false; + }()); + bool get fakeNetworkDisconnect => _fakeNetworkDisconnect; + set fakeNetworkDisconnect(bool newFakeNetworkDisconnect) { + _fakeNetworkDisconnect = newFakeNetworkDisconnect; + sharedPrefs.setBool( + SharedPrefsKeys.fakeNetworkDisconnect.name, + newFakeNetworkDisconnect, + ); + notifyListeners(); + } + + bool _useUnspecifiedAsFallbackOnly = false; + bool get useUnspecifiedAsFallbackOnly => _useUnspecifiedAsFallbackOnly; + set useUnspecifiedAsFallbackOnly(bool newUseUnspecifiedAsFallbackOnly) { + _useUnspecifiedAsFallbackOnly = newUseUnspecifiedAsFallbackOnly; notifyListeners(); } } diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 582b44f8..84e2eb3a 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -14,16 +14,16 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; import 'dart:isolate'; -import 'dart:math' as math; +import 'dart:math'; import 'dart:ui'; import 'package:async/async.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:http/http.dart' as http; +import 'package:http/http.dart' hide readBytes; +import 'package:http/http.dart' as http show readBytes; import 'package:http/io_client.dart'; import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart index 20d09c79..9b2911c4 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart @@ -14,22 +14,22 @@ Map _sharedWriteSingleTile({ final storesBox = root.box(); final rootBox = root.box(); - if (writeAllNotIn != null) { - storeNames.addAll( - storesBox - .getAll() - .map((e) => e.name) - .where((e) => !writeAllNotIn.contains(e) && !storeNames.contains(e)), - ); - } + final compiledStoreNames = writeAllNotIn == null + ? storeNames + : [ + ...storeNames, + ...storesBox.getAll().map((e) => e.name).where( + (e) => !writeAllNotIn.contains(e) && !storeNames.contains(e), + ), + ]; final tilesQuery = tiles.query(ObjectBoxTile_.url.equals(url)).build(); final storeQuery = - storesBox.query(ObjectBoxStore_.name.oneOf(storeNames)).build(); + storesBox.query(ObjectBoxStore_.name.oneOf(compiledStoreNames)).build(); final storesToUpdate = {}; - final result = {for (final storeName in storeNames) storeName: false}; + final result = {for (final storeName in compiledStoreNames) storeName: false}; root.runInTransaction( TxMode.write, @@ -45,7 +45,7 @@ Map _sharedWriteSingleTile({ for (final relatedStore in existingTile.stores) { didContainAlready - .addAll(storeNames.where((s) => s == relatedStore.name)); + .addAll(compiledStoreNames.where((s) => s == relatedStore.name)); storesToUpdate[relatedStore.name] = (storesToUpdate[relatedStore.name] ?? relatedStore) diff --git a/lib/src/bulk_download/external/tile_event.dart b/lib/src/bulk_download/external/tile_event.dart index 7404123f..12c5475c 100644 --- a/lib/src/bulk_download/external/tile_event.dart +++ b/lib/src/bulk_download/external/tile_event.dart @@ -113,14 +113,14 @@ class TileEvent { /// Remember to check [isRepeat] before keeping track of this value. final Uint8List? tileImage; - /// The raw [http.Response] from the [url], if available + /// The raw [Response] from the HTTP GET request to [url], if available /// /// Not available if [result] is [TileEventResult.noConnectionDuringFetch], /// [TileEventResult.unknownFetchException], or /// [TileEventResult.alreadyExisting]. /// /// Remember to check [isRepeat] before keeping track of this value. - final http.Response? fetchResponse; + final Response? fetchResponse; /// The raw error thrown when fetching from the [url], if available /// diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index 88774d07..66246d53 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -194,7 +194,7 @@ Future _downloadManager( id: recoveryId, storeName: input.storeName, region: input.region, - endTile: math.min(input.region.end ?? largestInt, maxTiles), + endTile: min(input.region.end ?? largestInt, maxTiles), ); } diff --git a/lib/src/bulk_download/internal/rate_limited_stream.dart b/lib/src/bulk_download/internal/rate_limited_stream.dart index b80013b0..02665f19 100644 --- a/lib/src/bulk_download/internal/rate_limited_stream.dart +++ b/lib/src/bulk_download/internal/rate_limited_stream.dart @@ -4,7 +4,6 @@ import 'dart:async'; /// Rate limiting extension, see [rateLimit] for more information -// TODO: check https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/throttle.html extension RateLimitedStream on Stream { /// Transforms a series of events to an output stream where a delay of at least /// [minimumSpacing] is inserted between every event diff --git a/lib/src/bulk_download/internal/thread.dart b/lib/src/bulk_download/internal/thread.dart index 455d2f23..58ec75a7 100644 --- a/lib/src/bulk_download/internal/thread.dart +++ b/lib/src/bulk_download/internal/thread.dart @@ -25,7 +25,7 @@ Future _singleDownloadThread( final tileQueue = StreamQueue(receivePort); // Initialise a long lasting HTTP client - final httpClient = http.Client(); + final httpClient = IOClient(); // Initialise the tile buffer arrays final tileUrlsBuffer = []; @@ -86,7 +86,7 @@ Future _singleDownloadThread( } // Fetch new tile from URL - final http.Response response; + final Response response; try { response = await httpClient.get(Uri.parse(networkUrl), headers: input.headers); diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index 41a138e6..17036ba5 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -91,8 +91,10 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { networkUrl: currentTLIR.networkUrl, storageSuitableUID: currentTLIR.storageSuitableUID, existingStores: currentTLIR.existingStores, - tileExistsInUnspecifiedStoresOnly: - currentTLIR.tileExistsInUnspecifiedStoresOnly, + tileRetrievedFromOtherStoresAsFallback: + currentTLIR.tileRetrievableFromOtherStoresAsFallback && + currentTLIR.resultPath == + TileLoadingInterceptorResultPath.cacheAsFallback, needsUpdating: currentTLIR.needsUpdating, hitOrMiss: currentTLIR.hitOrMiss, storesWriteResult: currentTLIR.storesWriteResult, diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index 9a9d2541..aa4d4619 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -53,17 +53,15 @@ Future _internalGetBytes({ currentTLIR?.existingStores = allExistingStores; } - final tileExistsInUnspecifiedStoresOnly = existingTile != null && + final tileRetrievableFromOtherStoresAsFallback = existingTile != null && provider.useOtherStoresAsFallbackOnly && provider.storeNames.keys .toSet() - .union( - allExistingStores.toSet(), - ) // TODO: Verify (intersect? simplify?) + .intersection(allExistingStores.toSet()) .isEmpty; - currentTLIR?.tileExistsInUnspecifiedStoresOnly = - tileExistsInUnspecifiedStoresOnly; + currentTLIR?.tileRetrievableFromOtherStoresAsFallback = + tileRetrievableFromOtherStoresAsFallback; // Prepare a list of image bytes and prefill if there's already a cached // tile available @@ -82,7 +80,7 @@ Future _internalGetBytes({ if (existingTile != null && !needsUpdating && - !tileExistsInUnspecifiedStoresOnly) { + !tileRetrievableFromOtherStoresAsFallback) { currentTLIR?.resultPath = TileLoadingInterceptorResultPath.perfectFromStores; @@ -106,33 +104,26 @@ Future _internalGetBytes({ networkUrl: networkUrl, storageSuitableUID: matcherUrl, ); - - // TODO: remove below - /*if (tileExistsInUnspecifiedStoresOnly) { - registerMiss(); - return bytes!; - } - if (existingTile == null) { - throw FMTCBrowsingError( - type: FMTCBrowsingErrorType.missingInCacheOnlyMode, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - ); - }*/ } // Setup a network request for the tile & handle network exceptions - final http.Response response; + final Response response; late final DateTime networkFetchStartTime; if (currentTLIR != null) networkFetchStartTime = DateTime.now(); try { + if (provider.fakeNetworkDisconnect) { + throw const SocketException( + 'Faked `SocketException` due to `fakeNetworkDisconnect`', + ); + } response = await provider.httpClient .get(Uri.parse(networkUrl), headers: provider.headers); } catch (e) { if (existingTile != null) { - currentTLIR?.resultPath = TileLoadingInterceptorResultPath.noFetch; + currentTLIR?.resultPath = + TileLoadingInterceptorResultPath.cacheAsFallback; registerMiss(); return bytes!; @@ -154,7 +145,8 @@ Future _internalGetBytes({ // Check whether the network response is not 200 OK if (response.statusCode != 200) { if (existingTile != null) { - currentTLIR?.resultPath = TileLoadingInterceptorResultPath.noFetch; + currentTLIR?.resultPath = + TileLoadingInterceptorResultPath.cacheAsFallback; registerMiss(); return bytes!; @@ -191,7 +183,8 @@ Future _internalGetBytes({ if (isValidImageData != null) { if (existingTile != null) { - currentTLIR?.resultPath = TileLoadingInterceptorResultPath.noFetch; + currentTLIR?.resultPath = + TileLoadingInterceptorResultPath.cacheAsFallback; registerMiss(); return bytes!; @@ -259,7 +252,7 @@ Future _internalGetBytes({ }); } - currentTLIR?.resultPath = TileLoadingInterceptorResultPath.fetched; + currentTLIR?.resultPath = TileLoadingInterceptorResultPath.fetchedFromNetwork; registerMiss(); return response.bodyBytes; diff --git a/lib/src/providers/tile_loading_interceptor/result.dart b/lib/src/providers/tile_loading_interceptor/result.dart index e4712994..07f85e0e 100644 --- a/lib/src/providers/tile_loading_interceptor/result.dart +++ b/lib/src/providers/tile_loading_interceptor/result.dart @@ -15,7 +15,7 @@ class _TLIRConstructor { late String networkUrl; late String storageSuitableUID; List? existingStores; - late bool tileExistsInUnspecifiedStoresOnly; + late bool tileRetrievableFromOtherStoresAsFallback; late bool needsUpdating; bool? hitOrMiss; Future>? storesWriteResult; @@ -33,7 +33,7 @@ class TileLoadingInterceptorResult { required this.networkUrl, required this.storageSuitableUID, required this.existingStores, - required this.tileExistsInUnspecifiedStoresOnly, + required this.tileRetrievedFromOtherStoresAsFallback, required this.needsUpdating, required this.hitOrMiss, required this.storesWriteResult, @@ -77,18 +77,24 @@ class TileLoadingInterceptorResult { /// If the tile already existed, the stores that it existed in/belonged to final List? existingStores; - /// Reflection of an internal indicator of the same name + /// Whether the tile was retrieved and used from an unspecified store as a + /// fallback + /// + /// Note that an attempt is *always* made to read the tile from the cache, + /// regardless of whether the tile is then actually retrieved from the cache + /// or the network is then used (successfully). /// /// Calculated with: /// /// ``` - /// && /// `useOtherStoresAsFallbackOnly` && - /// + /// `resultPath` == TileLoadingInterceptorResultPath.cacheAsFallback && + /// && + /// /// ``` - final bool tileExistsInUnspecifiedStoresOnly; + final bool tileRetrievedFromOtherStoresAsFallback; - /// Reflection of an internal indicator of the same name + /// Whether the tile was indicated for updating (excluding creating) /// /// Calculated with: /// @@ -119,8 +125,9 @@ class TileLoadingInterceptorResult { /// The duration of the operation used to attempt to read the existing tile /// from the store/cache /// - /// Even in [BrowseLoadingStrategy.onlineFirst] and where the tile is not used - /// from the local store/cache, the tile read attempt still occurs. + /// Note that even in [BrowseLoadingStrategy.onlineFirst] and where the tile + /// is not used from the local store/cache, an attempt is *always* made to + /// read the tile from the cache. final Duration cacheFetchDuration; /// The duration of the operation used to attempt to fetch the tile from the diff --git a/lib/src/providers/tile_loading_interceptor/result_path.dart b/lib/src/providers/tile_loading_interceptor/result_path.dart index a08fcb2d..91ccd768 100644 --- a/lib/src/providers/tile_loading_interceptor/result_path.dart +++ b/lib/src/providers/tile_loading_interceptor/result_path.dart @@ -18,8 +18,8 @@ enum TileLoadingInterceptorResultPath { cacheOnlyFromOtherStores, /// The tile was retrieved from the cache as a fallback - noFetch, + cacheAsFallback, /// The tile was newly fetched from the network - fetched, + fetchedFromNetwork, } diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index 2412a5e9..c4ee90c7 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -33,11 +33,16 @@ class FMTCTileProvider extends TileProvider { UrlTransformer? urlTransformer, this.errorHandler, this.tileLoadingInterceptor, - http.Client? httpClient, + Client? httpClient, + @visibleForTesting this.fakeNetworkDisconnect = false, Map? headers, - }) : assert(storeNames.isNotEmpty, '`storeNames` cannot be empty'), + }) : assert( + storeNames.isNotEmpty || otherStoresStrategy != null, + '`storeNames` cannot be empty if `otherStoresStrategy` is `null`', + ), urlTransformer = (urlTransformer ?? (u) => u), httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), + _wasClientAutomaticallyGenerated = httpClient == null, super( headers: (headers?.containsKey('User-Agent') ?? false) ? headers @@ -54,12 +59,14 @@ class FMTCTileProvider extends TileProvider { UrlTransformer? urlTransformer, this.errorHandler, this.tileLoadingInterceptor, - http.Client? httpClient, + Client? httpClient, + @visibleForTesting this.fakeNetworkDisconnect = false, Map? headers, }) : storeNames = const {}, otherStoresStrategy = allStoresStrategy, urlTransformer = (urlTransformer ?? (u) => u), httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), + _wasClientAutomaticallyGenerated = httpClient == null, super( headers: (headers?.containsKey('User-Agent') ?? false) ? headers @@ -80,8 +87,8 @@ class FMTCTileProvider extends TileProvider { /// /// `null` means that all other stores will not be used. /// - /// Setting a non-`null` value may reduce performance, as internal queries - /// will have fewer constraints and therefore be less efficient. + /// Setting a non-`null` value may negatively impact performance, because + /// internal tile cache lookups will have less constraints. /// /// Also see [useOtherStoresAsFallbackOnly] for whether these unspecified /// stores should only be used as a last resort or in addition to the specified @@ -101,9 +108,16 @@ class FMTCTileProvider extends TileProvider { /// When tiles are retrieved from other stores, it is counted as a miss for the /// specified store(s). /// - /// This may introduce notable performance reductions, especially if failures - /// occur often or the root is particularly large, as an extra lookup with - /// unbounded constraints is required for each tile. + /// Note that an attempt is *always* made to read the tile from the cache, + /// regardless of whether the tile is then actually retrieved from the cache + /// or the network is then used (successfully). + /// + /// For example, if a specified store does not contain the tile, and an + /// unspecified store does contain the tile: + /// * if this is `false`, then the tile will be retrieved and used from the + /// unspecified store + /// * if this is `true`, then the tile will be retrieved (see note above), + /// but not used unless the network request fails /// /// Defaults to `false`. final bool useOtherStoresAsFallbackOnly; @@ -181,12 +195,23 @@ class FMTCTileProvider extends TileProvider { /// [TileCoordinates]s to [TileLoadingInterceptorResult]s. final ValueNotifier? tileLoadingInterceptor; - /// [http.Client] (such as a [IOClient]) used to make all network requests + /// [Client] (such as a [IOClient]) used to make all network requests /// - /// Do not close manually. + /// If specified, then it will not be closed automatically on [dispose]al. + /// When closing manually, ensure no requests are currently underway, else + /// they will throw [ClientException]s. /// /// Defaults to a standard [IOClient]/[HttpClient]. - final http.Client httpClient; + final Client httpClient; + + /// Whether to fake a network disconnect for the purpose of testing + /// + /// When `true`, prevents a network request and instead throws a + /// [SocketException]. + /// + /// Defaults to `false`. + @visibleForTesting + final bool fakeNetworkDisconnect; /// Each [Completer] is completed once the corresponding tile has finished /// loading @@ -197,6 +222,8 @@ class FMTCTileProvider extends TileProvider { /// Does not include tiles loaded from session cache. final _tilesInProgress = HashMap>(); + final bool _wasClientAutomaticallyGenerated; + @override ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => _FMTCImageProvider( @@ -212,10 +239,12 @@ class FMTCTileProvider extends TileProvider { @override Future dispose() async { - if (_tilesInProgress.isNotEmpty) { - await Future.wait(_tilesInProgress.values.map((c) => c.future)); + if (_wasClientAutomaticallyGenerated) { + if (_tilesInProgress.isNotEmpty) { + await Future.wait(_tilesInProgress.values.map((c) => c.future)); + } + httpClient.close(); } - httpClient.close(); super.dispose(); } diff --git a/lib/src/providers/tile_provider/typedefs.dart b/lib/src/providers/tile_provider/typedefs.dart index ddc60640..5b1d752d 100644 --- a/lib/src/providers/tile_provider/typedefs.dart +++ b/lib/src/providers/tile_provider/typedefs.dart @@ -3,7 +3,8 @@ part of '../../../flutter_map_tile_caching.dart'; -/// Callback type for [FMTCTileProvider.urlTransformer] +/// Callback type for [FMTCTileProvider.urlTransformer] & +/// [StoreDownload.startForeground] typedef UrlTransformer = String Function(String); /// Callback type for [FMTCTileProvider.errorHandler] diff --git a/lib/src/regions/shapes/line.dart b/lib/src/regions/shapes/line.dart index 0ac03f41..b1ab80cf 100644 --- a/lib/src/regions/shapes/line.dart +++ b/lib/src/regions/shapes/line.dart @@ -31,7 +31,7 @@ class LineRegion extends BaseRegion { if (line.isEmpty) return; const dist = Distance(); - final rad = radius * math.pi / 4; + final rad = radius * pi / 4; for (int i = 0; i < line.length - 1; i++) { final cp = line[i]; diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index fd4c3b6c..f4058a0b 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -8,13 +8,14 @@ part of '../../flutter_map_tile_caching.dart'; /// --- /// /// {@template num_instances} -/// By default, only one download is allowed at any one time. +/// By default, only one download is allowed at any one time, across all stores. /// /// However, if necessary, multiple can be started by setting methods' /// `instanceId` argument to a unique value on methods. Whatever object /// `instanceId` is, it must have a valid and useful equality and `hashCode` /// implementation, as it is used as the key in a `Map`. Note that this unique /// value must be known and remembered to control the state of the download. +/// Note that instances are shared across all stores. /// /// > [!WARNING] /// > Starting multiple simultaneous downloads may lead to a noticeable @@ -267,6 +268,8 @@ class StoreDownload { /// This does not include skipped sea tiles or skipped existing tiles, as those /// are handled during download only. /// + /// Note that this does not require a valid/ready/existing store. + /// /// Returns the number of tiles. Future check(DownloadableRegion region) => compute( (region) => region.when( diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index d5730e7a..d8b62ebe 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -41,6 +41,9 @@ class FMTCStore { /// /// See other available [FMTCTileProvider] contructors to use multiple stores /// at once. See [FMTCTileProvider] for more info. + /// + /// [FMTCTileProvider.fakeNetworkDisconnect] cannot be set through this + /// shorthand for [FMTCTileProvider.multipleStores]. FMTCTileProvider getTileProvider({ BrowseStoreStrategy storeStrategy = BrowseStoreStrategy.readUpdateCreate, BrowseStoreStrategy? otherStoresStrategy, @@ -52,7 +55,7 @@ class FMTCStore { BrowsingExceptionHandler? errorHandler, ValueNotifier? tileLoadingInterceptor, Map? headers, - http.Client? httpClient, + Client? httpClient, }) => FMTCTileProvider.multipleStores( storeNames: {storeName: storeStrategy}, From d4f917a389651507e3bcc87d2e7567253faec93c Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 21 Aug 2024 12:22:43 +0100 Subject: [PATCH 50/97] Added compact store list layout design for example app --- .../bottom_sheet/components/contents.dart | 2 +- .../home/config_view/forms/side/side.dart | 2 +- .../browse_store_strategy_selector.dart | 191 +++++++++++++++ ...lumn_headers_and_inheritable_settings.dart | 140 +++++++---- .../store_read_write_behaviour_selector.dart | 127 ---------- .../components/store_tiles/store_tile.dart | 227 ++++++++++-------- .../store_tiles/unspecified_tile.dart | 15 +- .../panels/stores/stores_list.dart | 12 +- .../debugging_tile_builder.dart | 2 +- 9 files changed, 435 insertions(+), 283 deletions(-) create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/browse_store_strategy_selector.dart delete mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart index 684f7e4c..965f7d1f 100644 --- a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart +++ b/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart @@ -61,7 +61,7 @@ class _ContentPanelsState extends State<_ContentPanels> { ), const SliverToBoxAdapter(child: Divider(height: 24)), const SliverToBoxAdapter(child: SizedBox(height: 6)), - const StoresList(), + const StoresList(useCompactLayout: true), const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ); diff --git a/example/lib/src/screens/home/config_view/forms/side/side.dart b/example/lib/src/screens/home/config_view/forms/side/side.dart index 76045432..d2e314b1 100644 --- a/example/lib/src/screens/home/config_view/forms/side/side.dart +++ b/example/lib/src/screens/home/config_view/forms/side/side.dart @@ -65,7 +65,7 @@ class _ContentPanels extends StatelessWidget { slivers: [ SliverPadding( padding: EdgeInsets.only(top: 16), - sliver: StoresList(), + sliver: StoresList(useCompactLayout: false), ), SliverToBoxAdapter(child: SizedBox(height: 16)), ], diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/browse_store_strategy_selector.dart b/example/lib/src/screens/home/config_view/panels/stores/components/browse_store_strategy_selector.dart new file mode 100644 index 00000000..64c00240 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/browse_store_strategy_selector.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; +import '../../../../../../shared/state/general_provider.dart'; + +class BrowseStoreStrategySelector extends StatelessWidget { + const BrowseStoreStrategySelector({ + super.key, + required this.storeName, + required this.enabled, + this.inheritable = true, + required this.useCompactLayout, + }); + + final String storeName; + final bool enabled; + final bool inheritable; + final bool useCompactLayout; + + @override + Widget build(BuildContext context) => + Selector( + selector: (context, provider) => provider.currentStores[storeName], + builder: (context, currentStrategy, child) { + final inheritableStrategy = inheritable + ? context.select( + (provider) => provider.inheritableBrowseStoreStrategy, + ) + : null; + final resolvedCurrentStrategy = currentStrategy == null + ? inheritableStrategy + : currentStrategy.toBrowseStoreStrategy(inheritableStrategy); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (inheritable) ...[ + Checkbox.adaptive( + value: + currentStrategy == InternalBrowseStoreStrategy.inherit || + currentStrategy == null, + onChanged: enabled + ? (v) { + final provider = context.read(); + + provider + ..currentStores[storeName] = v! + ? InternalBrowseStoreStrategy.inherit + : InternalBrowseStoreStrategy + .fromBrowseStoreStrategy( + provider.inheritableBrowseStoreStrategy, + ) + ..changedCurrentStores(); + } + : null, + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, + ), + const VerticalDivider(width: 2), + ], + if (useCompactLayout) + _BrowseStoreStrategySelectorDropdown( + storeName: storeName, + currentStrategy: resolvedCurrentStrategy, + enabled: enabled, + ) + else + ...BrowseStoreStrategy.values.map( + (e) => _BrowseStoreStrategySelectorCheckbox( + storeName: storeName, + strategyOption: e, + currentStrategy: resolvedCurrentStrategy, + enabled: enabled, + ), + ), + ], + ); + }, + ); +} + +class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { + const _BrowseStoreStrategySelectorDropdown({ + required this.storeName, + required this.currentStrategy, + required this.enabled, + }); + + final String storeName; + final BrowseStoreStrategy? currentStrategy; + final bool enabled; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: DropdownButton( + items: [null] + .followedBy(BrowseStoreStrategy.values) + .map( + (e) => DropdownMenuItem( + value: e, + alignment: Alignment.center, + child: switch (e) { + null => const Icon(Icons.disabled_by_default_rounded), + BrowseStoreStrategy.read => const Icon(Icons.visibility), + BrowseStoreStrategy.readUpdate => const Icon(Icons.edit), + BrowseStoreStrategy.readUpdateCreate => + const Icon(Icons.add), + }, + ), + ) + .toList(), + value: currentStrategy, + onChanged: enabled + ? (BrowseStoreStrategy? v) { + final provider = context.read(); + + if (v == provider.inheritableBrowseStoreStrategy) { + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.inherit; + } else if (v == null) { + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.disable; + } else { + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.fromBrowseStoreStrategy( + v, + ); + } + + provider.changedCurrentStores(); + } + : null, + ), + ); +} + +class _BrowseStoreStrategySelectorCheckbox extends StatelessWidget { + const _BrowseStoreStrategySelectorCheckbox({ + required this.storeName, + required this.strategyOption, + required this.currentStrategy, + required this.enabled, + }); + + final String storeName; + final BrowseStoreStrategy strategyOption; + final BrowseStoreStrategy? currentStrategy; + final bool enabled; + + @override + Widget build(BuildContext context) => Checkbox.adaptive( + value: currentStrategy == strategyOption + ? true + : InternalBrowseStoreStrategy.priority.indexOf(currentStrategy) < + InternalBrowseStoreStrategy.priority.indexOf(strategyOption) + ? false + : null, + onChanged: enabled + ? (v) { + final provider = context.read(); + + if (v == null) { + // Deselected current selection + // > Disable inheritance and disable store + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.disable; + } else if (strategyOption == + provider.inheritableBrowseStoreStrategy) { + // Selected same as inherited + // > Automatically enable inheritance (assumed desire, can be undone) + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.inherit; + } else { + // Selected something else + // > Disable inheritance and change store + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.fromBrowseStoreStrategy( + strategyOption, + ); + } + provider.changedCurrentStores(); + } + : null, + tristate: true, + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart b/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart index 42476123..e54bd832 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart @@ -8,46 +8,59 @@ import '../../../../../../shared/state/general_provider.dart'; class ColumnHeadersAndInheritableSettings extends StatelessWidget { const ColumnHeadersAndInheritableSettings({ super.key, + required this.useCompactLayout, }); + final bool useCompactLayout; + @override Widget build(BuildContext context) => Column( children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 28), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 28) + + (useCompactLayout + ? const EdgeInsets.only(right: 32) + : EdgeInsets.zero), child: IntrinsicHeight( child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - Tooltip( + const Tooltip( message: 'Inherit', child: Padding( padding: EdgeInsets.symmetric(horizontal: 10), child: Icon(Icons.settings_suggest), ), ), - VerticalDivider(width: 2), - Tooltip( - message: 'Read only', - child: Padding( + const VerticalDivider(width: 2), + if (useCompactLayout) + const Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Icon(Icons.visibility), + child: Icon(Icons.settings), + ) + else ...[ + const Tooltip( + message: 'Read only', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.visibility), + ), ), - ), - Tooltip( - message: ' + update existing', - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 10), - child: Icon(Icons.edit), + const Tooltip( + message: ' + update existing', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.edit), + ), ), - ), - Tooltip( - message: ' + create new', - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 10), - child: Icon(Icons.add), + const Tooltip( + message: ' + create new', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.add), + ), ), - ), + ], ], ), ), @@ -58,31 +71,68 @@ class ColumnHeadersAndInheritableSettings extends StatelessWidget { child: Selector( selector: (context, provider) => provider.inheritableBrowseStoreStrategy, - builder: (context, currentBehaviour, child) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: BrowseStoreStrategy.values.map( - (e) { - final value = currentBehaviour == e - ? true - : InternalBrowseStoreStrategy.priority - .indexOf(currentBehaviour) < - InternalBrowseStoreStrategy.priority.indexOf(e) - ? false - : null; + builder: (context, currentBehaviour, child) { + if (useCompactLayout) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: DropdownButton( + items: [null] + .followedBy(BrowseStoreStrategy.values) + .map( + (e) => DropdownMenuItem( + value: e, + alignment: Alignment.center, + child: switch (e) { + null => const Icon( + Icons.disabled_by_default_rounded, + ), + BrowseStoreStrategy.read => + const Icon(Icons.visibility), + BrowseStoreStrategy.readUpdate => + const Icon(Icons.edit), + BrowseStoreStrategy.readUpdateCreate => + const Icon(Icons.add), + }, + ), + ) + .toList(), + value: currentBehaviour, + onChanged: (BrowseStoreStrategy? v) => context + .read() + .inheritableBrowseStoreStrategy = v, + ), + ), + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: BrowseStoreStrategy.values.map( + (e) { + final value = currentBehaviour == e + ? true + : InternalBrowseStoreStrategy.priority + .indexOf(currentBehaviour) < + InternalBrowseStoreStrategy.priority + .indexOf(e) + ? false + : null; - return Checkbox.adaptive( - value: value, - onChanged: (v) => context - .read() - .inheritableBrowseStoreStrategy = - v == null ? null : e, - tristate: true, - materialTapTargetSize: MaterialTapTargetSize.padded, - visualDensity: VisualDensity.comfortable, - ); - }, - ).toList(growable: false), - ), + return Checkbox.adaptive( + value: value, + onChanged: (v) => context + .read() + .inheritableBrowseStoreStrategy = + v == null ? null : e, + tristate: true, + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, + ); + }, + ).toList(growable: false), + ); + }, ), ), const Divider(height: 8, indent: 12, endIndent: 12), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart deleted file mode 100644 index 31893732..00000000 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_read_write_behaviour_selector.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; -import '../../../../../../shared/state/general_provider.dart'; - -class StoreReadWriteBehaviourSelector extends StatelessWidget { - const StoreReadWriteBehaviourSelector({ - super.key, - required this.storeName, - required this.enabled, - this.inheritable = true, - }); - - final String storeName; - final bool enabled; - final bool inheritable; - - @override - Widget build(BuildContext context) => - Selector( - selector: (context, provider) => provider.currentStores[storeName], - builder: (context, currentBehaviour, child) { - final inheritableBehaviour = inheritable - ? context.select( - (provider) => provider.inheritableBrowseStoreStrategy, - ) - : null; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (inheritable) ...[ - Checkbox.adaptive( - value: - currentBehaviour == InternalBrowseStoreStrategy.inherit || - currentBehaviour == null, - onChanged: enabled - ? (v) { - final provider = context.read(); - - provider - ..currentStores[storeName] = v! - ? InternalBrowseStoreStrategy.inherit - : InternalBrowseStoreStrategy - .fromBrowseStoreStrategy( - provider.inheritableBrowseStoreStrategy, - ) - ..changedCurrentStores(); - } - : null, - materialTapTargetSize: MaterialTapTargetSize.padded, - visualDensity: VisualDensity.comfortable, - ), - const VerticalDivider(width: 2), - ], - ...BrowseStoreStrategy.values.map( - (e) => _StoreReadWriteBehaviourSelectorCheckbox( - storeName: storeName, - representativeBehaviour: e, - currentBehaviour: currentBehaviour == null - ? inheritableBehaviour - : currentBehaviour - .toBrowseStoreStrategy(inheritableBehaviour), - enabled: enabled, - ), - ), - ], - ); - }, - ); -} - -class _StoreReadWriteBehaviourSelectorCheckbox extends StatelessWidget { - const _StoreReadWriteBehaviourSelectorCheckbox({ - required this.storeName, - required this.representativeBehaviour, - required this.currentBehaviour, - required this.enabled, - }); - - final String storeName; - final BrowseStoreStrategy representativeBehaviour; - final BrowseStoreStrategy? currentBehaviour; - final bool enabled; - - @override - Widget build(BuildContext context) => Checkbox.adaptive( - value: currentBehaviour == representativeBehaviour - ? true - : InternalBrowseStoreStrategy.priority.indexOf(currentBehaviour) < - InternalBrowseStoreStrategy.priority - .indexOf(representativeBehaviour) - ? false - : null, - onChanged: enabled - ? (v) { - final provider = context.read(); - - if (v == null) { - // Deselected current selection - // > Disable inheritance and disable store - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.disable; - } else if (representativeBehaviour == - provider.inheritableBrowseStoreStrategy) { - // Selected same as inherited - // > Automatically enable inheritance (assumed desire, can be undone) - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.inherit; - } else { - // Selected something else - // > Disable inheritance and change store - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.fromBrowseStoreStrategy( - representativeBehaviour, - ); - } - provider.changedCurrentStores(); - } - : null, - tristate: true, - materialTapTargetSize: MaterialTapTargetSize.padded, - visualDensity: VisualDensity.comfortable, - ); -} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart index 842e87ee..4f05d0b3 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart @@ -9,7 +9,7 @@ import '../../../../../../../shared/misc/store_metadata_keys.dart'; import '../../../../../../../shared/state/general_provider.dart'; import '../../../../../../store_editor/store_editor.dart'; import '../../state/export_selection_provider.dart'; -import '../store_read_write_behaviour_selector.dart'; +import '../browse_store_strategy_selector.dart'; class StoreTile extends StatefulWidget { const StoreTile({ @@ -18,12 +18,14 @@ class StoreTile extends StatefulWidget { required this.stats, required this.metadata, required this.tileImage, + required this.useCompactLayout, }); final FMTCStore store; final Future<({int hits, int length, int misses, double size})> stats; final Future> metadata; final Future tileImage; + final bool useCompactLayout; @override State createState() => _StoreTileState(); @@ -58,14 +60,21 @@ class _StoreTileState extends State { metadataSnapshot.data![StoreMetadataKeys.urlTemplate.key]; final toolsChildren = [ + const SizedBox(width: 4), IconButton( onPressed: _exportStore, icon: const Icon(Icons.send_and_archive), + visualDensity: + widget.useCompactLayout ? VisualDensity.compact : null, ), + const SizedBox(width: 4), IconButton( onPressed: _editStore, icon: const Icon(Icons.edit), + visualDensity: + widget.useCompactLayout ? VisualDensity.compact : null, ), + const SizedBox(width: 4), FutureBuilder( future: widget.stats, builder: (context, statsSnapshot) { @@ -76,6 +85,9 @@ class _StoreTileState extends State { Icons.delete_forever, color: Colors.red, ), + visualDensity: widget.useCompactLayout + ? VisualDensity.compact + : null, ); } @@ -83,7 +95,7 @@ class _StoreTileState extends State { return const IconButton( onPressed: null, icon: SizedBox.square( - dimension: 22, + dimension: 18, child: Center( child: CircularProgressIndicator.adaptive( strokeWidth: 3, @@ -96,9 +108,13 @@ class _StoreTileState extends State { return IconButton( onPressed: _emptyStore, icon: const Icon(Icons.delete), + visualDensity: widget.useCompactLayout + ? VisualDensity.compact + : null, ); }, ), + const SizedBox(width: 4), ]; final exportModeChildren = [ @@ -157,125 +173,132 @@ class _StoreTileState extends State { ), ), ), - trailing: IntrinsicWidth( - child: IntrinsicHeight( - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.all(4), - child: StoreReadWriteBehaviourSelector( - storeName: widget.store.storeName, - enabled: matchesUrl, + trailing: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: IntrinsicWidth( + child: IntrinsicHeight( + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(4), + child: BrowseStoreStrategySelector( + storeName: widget.store.storeName, + enabled: matchesUrl, + useCompactLayout: widget.useCompactLayout, + ), + ), ), - ), - AnimatedOpacity( - opacity: matchesUrl ? 0 : 1, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: matchesUrl, - child: SizedBox.expand( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .error - .withOpacity(0.75), - borderRadius: BorderRadius.circular(12), - ), - child: const Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - Icon(Icons.link_off, color: Colors.white), - Text( - 'URL mismatch', - style: TextStyle( - color: Colors.white, - fontSize: 14, + AnimatedOpacity( + opacity: matchesUrl ? 0 : 1, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: matchesUrl, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .error + .withOpacity(0.75), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + Icon(Icons.link_off, + color: Colors.white), + Text( + 'URL mismatch', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), ), - ), - ], + ], + ), ), ), ), ), - ), - AnimatedOpacity( - opacity: _toolsVisible ? 1 : 0, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: !_toolsVisible, - child: SizedBox.expand( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceDim, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, + AnimatedOpacity( + opacity: _toolsVisible ? 1 : 0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: !_toolsVisible, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceDim, + borderRadius: BorderRadius.circular(12), ), - child: _toolsDeleteLoading - ? const Center( - child: SizedBox.square( - dimension: 25, - child: Center( - child: CircularProgressIndicator - .adaptive(), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + ), + child: _toolsDeleteLoading + ? const Center( + child: SizedBox.square( + dimension: 25, + child: Center( + child: + CircularProgressIndicator + .adaptive(), + ), ), + ) + : Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: toolsChildren, ), - ) - : Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: toolsChildren, - ), + ), ), ), ), ), - ), - AnimatedOpacity( - opacity: exportSelectionProvider - .selectedStores.isNotEmpty - ? 1 - : 0, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: exportSelectionProvider - .selectedStores.isEmpty, - child: SizedBox.expand( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceDim, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, + AnimatedOpacity( + opacity: exportSelectionProvider + .selectedStores.isNotEmpty + ? 1 + : 0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: exportSelectionProvider + .selectedStores.isEmpty, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceDim, + borderRadius: BorderRadius.circular(12), ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: exportModeChildren, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: exportModeChildren, + ), ), ), ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart index c3e15f8c..87952efa 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart @@ -3,13 +3,16 @@ import 'package:provider/provider.dart'; import '../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; import '../../../../../../../shared/state/general_provider.dart'; -import '../store_read_write_behaviour_selector.dart'; +import '../browse_store_strategy_selector.dart'; class UnspecifiedTile extends StatefulWidget { const UnspecifiedTile({ super.key, + required this.useCompactLayout, }); + final bool useCompactLayout; + @override State createState() => _UnspecifiedTileState(); } @@ -29,10 +32,12 @@ class _UnspecifiedTileState extends State { color: Colors.transparent, child: ListTile( title: const Text( - 'All URL matching unselected', + 'All disabled', maxLines: 2, overflow: TextOverflow.fade, + style: TextStyle(fontStyle: FontStyle.italic), ), + subtitle: const Text('(matching URL)'), leading: const SizedBox.square( dimension: 48, child: Icon(Icons.unpublished, size: 28), @@ -47,7 +52,7 @@ class _UnspecifiedTileState extends State { mainAxisSize: MainAxisSize.min, children: [ Container( - padding: const EdgeInsets.all(6), + padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceDim, borderRadius: BorderRadius.circular(99), @@ -78,11 +83,13 @@ class _UnspecifiedTileState extends State { ), const SizedBox(width: 8), const VerticalDivider(width: 2), - const StoreReadWriteBehaviourSelector( + BrowseStoreStrategySelector( storeName: '(unspecified)', enabled: true, inheritable: false, + useCompactLayout: widget.useCompactLayout, ), + if (widget.useCompactLayout) const SizedBox(width: 12), ], ), ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart index f4b939d3..83bc0a63 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart @@ -15,8 +15,11 @@ import 'state/export_selection_provider.dart'; class StoresList extends StatefulWidget { const StoresList({ super.key, + required this.useCompactLayout, }); + final bool useCompactLayout; + @override State createState() => _StoresListState(); } @@ -73,10 +76,14 @@ class _StoresListState extends State { itemCount: stores.length + 4, itemBuilder: (context, index) { if (index == 0) { - return const ColumnHeadersAndInheritableSettings(); + return ColumnHeadersAndInheritableSettings( + useCompactLayout: widget.useCompactLayout, + ); } if (index - 1 == stores.length) { - return const UnspecifiedTile(); + return UnspecifiedTile( + useCompactLayout: widget.useCompactLayout, + ); } if (index - 2 == stores.length) { return RootTile( @@ -107,6 +114,7 @@ class _StoresListState extends State { stats: stats, metadata: metadata, tileImage: tileImage, + useCompactLayout: widget.useCompactLayout, ); }, separatorBuilder: (context, index) => index - 3 == stores.length - 1 diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart b/example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart index 47433fa4..d9b4e594 100644 --- a/example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart +++ b/example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart @@ -42,7 +42,7 @@ class DebuggingTileBuilder extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.disabled_by_default_rounded, size: 32), + Icon(Icons.file_download_off, size: 32), SizedBox(height: 6), Text('FMTC not in use'), ], From c50a670b6147922c4df48106b7878fddf351df30 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 22 Aug 2024 16:33:22 +0100 Subject: [PATCH 51/97] Improved example app --- .../home/config_view/forms/side/side.dart | 12 +- .../browse_store_strategy_selector.dart | 191 ------------------ .../components/export_stores/button.dart | 7 +- .../stores/components/new_store_button.dart | 7 +- .../panels/stores/components/no_stores.dart | 7 +- .../browse_store_strategy_selector.dart | 125 ++++++++++++ .../checkbox.dart | 63 ++++++ .../dropdown.dart | 91 +++++++++ .../components/store_tiles/store_tile.dart | 8 +- .../store_tiles/unspecified_tile.dart | 2 +- .../additional_panes/line_region_pane.dart | 62 +++--- .../region_selection/side_panel/parent.dart | 1 - 12 files changed, 326 insertions(+), 250 deletions(-) delete mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/browse_store_strategy_selector.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart create mode 100644 example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart diff --git a/example/lib/src/screens/home/config_view/forms/side/side.dart b/example/lib/src/screens/home/config_view/forms/side/side.dart index d2e314b1..6abdef3b 100644 --- a/example/lib/src/screens/home/config_view/forms/side/side.dart +++ b/example/lib/src/screens/home/config_view/forms/side/side.dart @@ -39,7 +39,7 @@ class _ContentPanels extends StatelessWidget { Widget build(BuildContext context) => Padding( padding: const EdgeInsets.only(right: 16, top: 16), child: SizedBox( - width: (constraints.maxWidth / 3).clamp(515, 560), + width: (constraints.maxWidth / 3).clamp(440, 560), child: Column( children: [ Container( @@ -61,13 +61,15 @@ class _ContentPanels extends StatelessWidget { ), width: double.infinity, height: double.infinity, - child: const CustomScrollView( + child: CustomScrollView( slivers: [ SliverPadding( - padding: EdgeInsets.only(top: 16), - sliver: StoresList(useCompactLayout: false), + padding: const EdgeInsets.only(top: 16), + sliver: StoresList( + useCompactLayout: constraints.maxWidth / 3 < 500, + ), ), - SliverToBoxAdapter(child: SizedBox(height: 16)), + const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/browse_store_strategy_selector.dart b/example/lib/src/screens/home/config_view/panels/stores/components/browse_store_strategy_selector.dart deleted file mode 100644 index 64c00240..00000000 --- a/example/lib/src/screens/home/config_view/panels/stores/components/browse_store_strategy_selector.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; -import '../../../../../../shared/state/general_provider.dart'; - -class BrowseStoreStrategySelector extends StatelessWidget { - const BrowseStoreStrategySelector({ - super.key, - required this.storeName, - required this.enabled, - this.inheritable = true, - required this.useCompactLayout, - }); - - final String storeName; - final bool enabled; - final bool inheritable; - final bool useCompactLayout; - - @override - Widget build(BuildContext context) => - Selector( - selector: (context, provider) => provider.currentStores[storeName], - builder: (context, currentStrategy, child) { - final inheritableStrategy = inheritable - ? context.select( - (provider) => provider.inheritableBrowseStoreStrategy, - ) - : null; - final resolvedCurrentStrategy = currentStrategy == null - ? inheritableStrategy - : currentStrategy.toBrowseStoreStrategy(inheritableStrategy); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (inheritable) ...[ - Checkbox.adaptive( - value: - currentStrategy == InternalBrowseStoreStrategy.inherit || - currentStrategy == null, - onChanged: enabled - ? (v) { - final provider = context.read(); - - provider - ..currentStores[storeName] = v! - ? InternalBrowseStoreStrategy.inherit - : InternalBrowseStoreStrategy - .fromBrowseStoreStrategy( - provider.inheritableBrowseStoreStrategy, - ) - ..changedCurrentStores(); - } - : null, - materialTapTargetSize: MaterialTapTargetSize.padded, - visualDensity: VisualDensity.comfortable, - ), - const VerticalDivider(width: 2), - ], - if (useCompactLayout) - _BrowseStoreStrategySelectorDropdown( - storeName: storeName, - currentStrategy: resolvedCurrentStrategy, - enabled: enabled, - ) - else - ...BrowseStoreStrategy.values.map( - (e) => _BrowseStoreStrategySelectorCheckbox( - storeName: storeName, - strategyOption: e, - currentStrategy: resolvedCurrentStrategy, - enabled: enabled, - ), - ), - ], - ); - }, - ); -} - -class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { - const _BrowseStoreStrategySelectorDropdown({ - required this.storeName, - required this.currentStrategy, - required this.enabled, - }); - - final String storeName; - final BrowseStoreStrategy? currentStrategy; - final bool enabled; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: DropdownButton( - items: [null] - .followedBy(BrowseStoreStrategy.values) - .map( - (e) => DropdownMenuItem( - value: e, - alignment: Alignment.center, - child: switch (e) { - null => const Icon(Icons.disabled_by_default_rounded), - BrowseStoreStrategy.read => const Icon(Icons.visibility), - BrowseStoreStrategy.readUpdate => const Icon(Icons.edit), - BrowseStoreStrategy.readUpdateCreate => - const Icon(Icons.add), - }, - ), - ) - .toList(), - value: currentStrategy, - onChanged: enabled - ? (BrowseStoreStrategy? v) { - final provider = context.read(); - - if (v == provider.inheritableBrowseStoreStrategy) { - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.inherit; - } else if (v == null) { - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.disable; - } else { - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.fromBrowseStoreStrategy( - v, - ); - } - - provider.changedCurrentStores(); - } - : null, - ), - ); -} - -class _BrowseStoreStrategySelectorCheckbox extends StatelessWidget { - const _BrowseStoreStrategySelectorCheckbox({ - required this.storeName, - required this.strategyOption, - required this.currentStrategy, - required this.enabled, - }); - - final String storeName; - final BrowseStoreStrategy strategyOption; - final BrowseStoreStrategy? currentStrategy; - final bool enabled; - - @override - Widget build(BuildContext context) => Checkbox.adaptive( - value: currentStrategy == strategyOption - ? true - : InternalBrowseStoreStrategy.priority.indexOf(currentStrategy) < - InternalBrowseStoreStrategy.priority.indexOf(strategyOption) - ? false - : null, - onChanged: enabled - ? (v) { - final provider = context.read(); - - if (v == null) { - // Deselected current selection - // > Disable inheritance and disable store - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.disable; - } else if (strategyOption == - provider.inheritableBrowseStoreStrategy) { - // Selected same as inherited - // > Automatically enable inheritance (assumed desire, can be undone) - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.inherit; - } else { - // Selected something else - // > Disable inheritance and change store - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.fromBrowseStoreStrategy( - strategyOption, - ); - } - provider.changedCurrentStores(); - } - : null, - tristate: true, - materialTapTargetSize: MaterialTapTargetSize.padded, - visualDensity: VisualDensity.comfortable, - ); -} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart index 01c31213..4392c621 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart @@ -49,10 +49,9 @@ class ExportStoresButton extends StatelessWidget { const SizedBox(height: 24), Text( 'Within the example app, for simplicity, each store contains ' - 'tiles from a single URL template. This is not a limitation ' - 'with FMTC.\nAdditionally, FMTC supports changing the ' - 'read/write behaviour for all unspecified stores, but this ' - 'is not represented wihtin this app.', + 'tiles from a single URL template. Additionally, only one tile ' + 'layer with a single URL template can be used at any one time. ' + 'These are not limitations with FMTC.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall, ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart b/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart index a88a4193..a8cf7367 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart @@ -37,10 +37,9 @@ class NewStoreButton extends StatelessWidget { const SizedBox(height: 24), Text( 'Within the example app, for simplicity, each store contains ' - 'tiles from a single URL template. This is not a limitation ' - 'with FMTC.\nAdditionally, FMTC supports changing the ' - 'read/write behaviour for all unspecified stores, but this ' - 'is not represented wihtin this app.', + 'tiles from a single URL template. Additionally, only one tile ' + 'layer with a single URL template can be used at any one time. ' + 'These are not limitations with FMTC.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall, ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart b/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart index e42ce38f..f2a8428e 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart @@ -53,10 +53,9 @@ class NoStores extends StatelessWidget { const SizedBox(height: 32), Text( 'Within the example app, for simplicity, each store contains ' - 'tiles from a single URL template. This is not a limitation ' - 'with FMTC.\nAdditionally, FMTC supports changing the ' - 'read/write behaviour for all unspecified stores, but this ' - 'is not represented wihtin this app.', + 'tiles from a single URL template. Additionally, only one ' + 'tile layer with a single URL template can be used at any ' + 'one time. These are not limitations with FMTC.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall, ), diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart new file mode 100644 index 00000000..b49f2d9a --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; +import '../../../../../../../../shared/state/general_provider.dart'; + +part 'checkbox.dart'; +part 'dropdown.dart'; + +class BrowseStoreStrategySelector extends StatelessWidget { + const BrowseStoreStrategySelector({ + super.key, + required this.storeName, + required this.enabled, + this.inheritable = true, + required this.useCompactLayout, + }); + + final String storeName; + final bool enabled; + final bool inheritable; + final bool useCompactLayout; + + static const _unspecifiedSelectorColor = Colors.pinkAccent; + + @override + Widget build(BuildContext context) { + final currentStrategy = + context.select( + (provider) => provider.currentStores[storeName], + ); + final unspecifiedStrategy = + context.select( + (provider) => provider.currentStores['(unspecified)'], + ); + final inheritableStrategy = inheritable + ? context.select( + (provider) => provider.inheritableBrowseStoreStrategy, + ) + : null; + + final resolvedCurrentStrategy = currentStrategy == null + ? inheritableStrategy + : currentStrategy.toBrowseStoreStrategy(inheritableStrategy); + final isUsingUnselectedStrategy = resolvedCurrentStrategy == null && + unspecifiedStrategy != InternalBrowseStoreStrategy.disable && + enabled; + + // ignore: avoid_positional_boolean_parameters + void changedInheritCheckbox(bool? value) { + final provider = context.read(); + + provider + ..currentStores[storeName] = value! + ? InternalBrowseStoreStrategy.inherit + : InternalBrowseStoreStrategy.fromBrowseStoreStrategy( + provider.inheritableBrowseStoreStrategy, + ) + ..changedCurrentStores(); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (inheritable) ...[ + Checkbox.adaptive( + value: currentStrategy == InternalBrowseStoreStrategy.inherit || + currentStrategy == null, + onChanged: enabled ? changedInheritCheckbox : null, + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, + ), + const VerticalDivider(width: 2), + ], + if (useCompactLayout) + _BrowseStoreStrategySelectorDropdown( + storeName: storeName, + currentStrategy: resolvedCurrentStrategy, + enabled: enabled, + isUnspecifiedSelector: storeName == '(unspecified)', + isUsingUnselectedStrategy: isUsingUnselectedStrategy, + ) + else + Stack( + children: [ + Transform.translate( + offset: const Offset(2, 0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: BrowseStoreStrategySelector._unspecifiedSelectorColor + .withOpacity(0.75), + borderRadius: BorderRadius.circular(99), + ), + width: isUsingUnselectedStrategy + ? switch (unspecifiedStrategy) { + InternalBrowseStoreStrategy.read => 40, + InternalBrowseStoreStrategy.readUpdate => 88, + InternalBrowseStoreStrategy.readUpdateCreate => 130, + _ => 0, + } + : 0, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...BrowseStoreStrategy.values.map( + (e) => _BrowseStoreStrategySelectorCheckbox( + strategyOption: e, + storeName: storeName, + currentStrategy: resolvedCurrentStrategy, + enabled: enabled, + isUnspecifiedSelector: storeName == '(unspecified)', + ), + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart new file mode 100644 index 00000000..e72ebaf3 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart @@ -0,0 +1,63 @@ +part of 'browse_store_strategy_selector.dart'; + +class _BrowseStoreStrategySelectorCheckbox extends StatelessWidget { + const _BrowseStoreStrategySelectorCheckbox({ + required this.strategyOption, + required this.storeName, + required this.currentStrategy, + required this.enabled, + required this.isUnspecifiedSelector, + }); + + final BrowseStoreStrategy strategyOption; + final String storeName; + final BrowseStoreStrategy? currentStrategy; + final bool enabled; + final bool isUnspecifiedSelector; + + @override + Widget build(BuildContext context) => Checkbox.adaptive( + value: currentStrategy == strategyOption + ? true + : InternalBrowseStoreStrategy.priority.indexOf(currentStrategy) < + InternalBrowseStoreStrategy.priority.indexOf(strategyOption) + ? false + : null, + onChanged: enabled + ? (v) { + final provider = context.read(); + + if (v == null) { + // Deselected current selection + // > Disable inheritance and disable store + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.disable; + } else if (strategyOption == + provider.inheritableBrowseStoreStrategy) { + // Selected same as inherited + // > Automatically enable inheritance (assumed desire, can be undone) + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.inherit; + } else { + // Selected something else + // > Disable inheritance and change store + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.fromBrowseStoreStrategy( + strategyOption, + ); + } + provider.changedCurrentStores(); + } + : null, + tristate: true, + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, + activeColor: isUnspecifiedSelector + ? BrowseStoreStrategySelector._unspecifiedSelectorColor + : null, + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.isEmpty) return Theme.of(context).colorScheme.surface; + return null; + }), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart new file mode 100644 index 00000000..f444b0f9 --- /dev/null +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart @@ -0,0 +1,91 @@ +part of 'browse_store_strategy_selector.dart'; + +class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { + const _BrowseStoreStrategySelectorDropdown({ + required this.storeName, + required this.currentStrategy, + required this.enabled, + required this.isUnspecifiedSelector, + required this.isUsingUnselectedStrategy, + }); + + final String storeName; + final BrowseStoreStrategy? currentStrategy; + final bool enabled; + final bool isUnspecifiedSelector; + final bool isUsingUnselectedStrategy; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: DropdownButton( + items: [null, ...BrowseStoreStrategy.values].map( + (e) { + final iconColor = isUnspecifiedSelector + ? BrowseStoreStrategySelector._unspecifiedSelectorColor + : null; + + final child = switch (e) { + null => isUsingUnselectedStrategy + ? switch (context + .select( + (provider) => provider.currentStores['(unspecified)'], + )) { + InternalBrowseStoreStrategy.read => const Icon( + Icons.visibility, + color: BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + InternalBrowseStoreStrategy.readUpdate => const Icon( + Icons.edit, + color: BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + InternalBrowseStoreStrategy.readUpdateCreate => + const Icon( + Icons.add, + color: BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + _ => const Icon(Icons.disabled_by_default_rounded), + } + : const Icon(Icons.disabled_by_default_rounded), + BrowseStoreStrategy.read => + Icon(Icons.visibility, color: iconColor), + BrowseStoreStrategy.readUpdate => + Icon(Icons.edit, color: iconColor), + BrowseStoreStrategy.readUpdateCreate => + Icon(Icons.add, color: iconColor), + }; + + return DropdownMenuItem( + value: e, + alignment: Alignment.center, + child: child, + ); + }, + ).toList(), + value: currentStrategy, + onChanged: enabled + ? (BrowseStoreStrategy? v) { + final provider = context.read(); + + if (v == provider.inheritableBrowseStoreStrategy) { + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.inherit; + } else if (v == null) { + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.disable; + } else { + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.fromBrowseStoreStrategy( + v, + ); + } + + provider.changedCurrentStores(); + } + : null, + ), + ); +} diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart index 4f05d0b3..2c4ecd7a 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart @@ -9,7 +9,7 @@ import '../../../../../../../shared/misc/store_metadata_keys.dart'; import '../../../../../../../shared/state/general_provider.dart'; import '../../../../../../store_editor/store_editor.dart'; import '../../state/export_selection_provider.dart'; -import '../browse_store_strategy_selector.dart'; +import 'browse_store_strategy_selector/browse_store_strategy_selector.dart'; class StoreTile extends StatefulWidget { const StoreTile({ @@ -211,8 +211,10 @@ class _StoreTileState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Icon(Icons.link_off, - color: Colors.white), + Icon( + Icons.link_off, + color: Colors.white, + ), Text( 'URL mismatch', style: TextStyle( diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart index 87952efa..b08e2424 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart @@ -3,7 +3,7 @@ import 'package:provider/provider.dart'; import '../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; import '../../../../../../../shared/state/general_provider.dart'; -import '../browse_store_strategy_selector.dart'; +import 'browse_store_strategy_selector/browse_store_strategy_selector.dart'; class UnspecifiedTile extends StatefulWidget { const UnspecifiedTile({ diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart index 894773c1..75288d1e 100644 --- a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart +++ b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart @@ -19,45 +19,33 @@ class LineRegionPane extends StatelessWidget { onPressed: () async { final provider = context.read(); - if (Platform.isAndroid || Platform.isIOS) { - await FilePicker.platform.clearTemporaryFiles(); - } + final pickerResult = Platform.isAndroid || Platform.isIOS + ? await FilePicker.platform.pickFiles( + allowMultiple: true, + ) + : await FilePicker.platform.pickFiles( + dialogTitle: 'Import GPX', + type: FileType.custom, + allowedExtensions: ['gpx'], + allowMultiple: true, + ); - late final FilePickerResult? result; - try { - result = await FilePicker.platform.pickFiles( - dialogTitle: 'Parse From GPX', - type: FileType.custom, - allowedExtensions: ['gpx', 'kml'], - allowMultiple: true, - ); - } on PlatformException catch (_) { - result = await FilePicker.platform.pickFiles( - dialogTitle: 'Parse From GPX', - allowMultiple: true, - ); - } + if (pickerResult == null) return; - if (result != null) { - final gpxReader = GpxReader(); - for (final path in result.files.map((e) => e.path)) { - provider.addCoordinates( - gpxReader - .fromString( - await File(path!).readAsString(), - ) - .trks - .map( - (e) => e.trksegs.map( - (e) => e.trkpts.map( - (e) => LatLng(e.lat!, e.lon!), - ), - ), - ) - .expand((e) => e) - .expand((e) => e), - ); - } + final gpxReader = GpxReader(); + for (final path in pickerResult.files.map((e) => e.path)) { + provider.addCoordinates( + gpxReader + .fromString(await File(path!).readAsString()) + .trks + .map( + (e) => e.trksegs.map( + (e) => e.trkpts.map((e) => LatLng(e.lat!, e.lon!)), + ), + ) + .expand((e) => e) + .expand((e) => e), + ); } }, icon: const Icon(Icons.route), diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart index 16365970..04a62118 100644 --- a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart +++ b/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:gpx/gpx.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; From a77390f5d4aea51979a8033af5fa669f701a12f8 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 24 Aug 2024 14:22:56 +0100 Subject: [PATCH 52/97] Fixed bug in example app --- .../browse_store_strategy_selector.dart | 5 +- .../checkbox.dart | 3 +- .../dropdown.dart | 68 +++++++++++-------- .../src/shared/state/general_provider.dart | 5 +- 4 files changed, 46 insertions(+), 35 deletions(-) diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart index b49f2d9a..2785eeb0 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart @@ -79,7 +79,6 @@ class BrowseStoreStrategySelector extends StatelessWidget { currentStrategy: resolvedCurrentStrategy, enabled: enabled, isUnspecifiedSelector: storeName == '(unspecified)', - isUsingUnselectedStrategy: isUsingUnselectedStrategy, ) else Stack( @@ -96,8 +95,8 @@ class BrowseStoreStrategySelector extends StatelessWidget { width: isUsingUnselectedStrategy ? switch (unspecifiedStrategy) { InternalBrowseStoreStrategy.read => 40, - InternalBrowseStoreStrategy.readUpdate => 88, - InternalBrowseStoreStrategy.readUpdateCreate => 130, + InternalBrowseStoreStrategy.readUpdate => 85, + InternalBrowseStoreStrategy.readUpdateCreate => 128, _ => 0, } : 0, diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart index e72ebaf3..31ff1e03 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart @@ -33,7 +33,8 @@ class _BrowseStoreStrategySelectorCheckbox extends StatelessWidget { provider.currentStores[storeName] = InternalBrowseStoreStrategy.disable; } else if (strategyOption == - provider.inheritableBrowseStoreStrategy) { + provider.inheritableBrowseStoreStrategy && + !isUnspecifiedSelector) { // Selected same as inherited // > Automatically enable inheritance (assumed desire, can be undone) provider.currentStores[storeName] = diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart index f444b0f9..8bfce451 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart +++ b/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart @@ -6,14 +6,12 @@ class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { required this.currentStrategy, required this.enabled, required this.isUnspecifiedSelector, - required this.isUsingUnselectedStrategy, }); final String storeName; final BrowseStoreStrategy? currentStrategy; final bool enabled; final bool isUnspecifiedSelector; - final bool isUsingUnselectedStrategy; @override Widget build(BuildContext context) => Padding( @@ -26,30 +24,39 @@ class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { : null; final child = switch (e) { - null => isUsingUnselectedStrategy - ? switch (context - .select( - (provider) => provider.currentStores['(unspecified)'], - )) { - InternalBrowseStoreStrategy.read => const Icon( - Icons.visibility, - color: BrowseStoreStrategySelector - ._unspecifiedSelectorColor, - ), - InternalBrowseStoreStrategy.readUpdate => const Icon( - Icons.edit, - color: BrowseStoreStrategySelector - ._unspecifiedSelectorColor, - ), - InternalBrowseStoreStrategy.readUpdateCreate => - const Icon( - Icons.add, - color: BrowseStoreStrategySelector - ._unspecifiedSelectorColor, - ), - _ => const Icon(Icons.disabled_by_default_rounded), - } - : const Icon(Icons.disabled_by_default_rounded), + null when !enabled => const Icon( + Icons.disabled_by_default_rounded, + ), + null when isUnspecifiedSelector => const Icon( + Icons.disabled_by_default_rounded, + color: + BrowseStoreStrategySelector._unspecifiedSelectorColor, + ), + null => switch (context + .select( + (provider) => provider.currentStores['(unspecified)'], + )) { + InternalBrowseStoreStrategy.read => const Icon( + Icons.visibility, + color: BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + InternalBrowseStoreStrategy.readUpdate => const Icon( + Icons.edit, + color: BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + InternalBrowseStoreStrategy.readUpdateCreate => const Icon( + Icons.add, + color: BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + _ => const Icon( + Icons.disabled_by_default_rounded, + color: BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + }, BrowseStoreStrategy.read => Icon(Icons.visibility, color: iconColor), BrowseStoreStrategy.readUpdate => @@ -70,12 +77,13 @@ class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { ? (BrowseStoreStrategy? v) { final provider = context.read(); - if (v == provider.inheritableBrowseStoreStrategy) { - provider.currentStores[storeName] = - InternalBrowseStoreStrategy.inherit; - } else if (v == null) { + if (v == null) { provider.currentStores[storeName] = InternalBrowseStoreStrategy.disable; + } else if (v == provider.inheritableBrowseStoreStrategy && + !isUnspecifiedSelector) { + provider.currentStores[storeName] = + InternalBrowseStoreStrategy.inherit; } else { provider.currentStores[storeName] = InternalBrowseStoreStrategy.fromBrowseStoreStrategy( diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart index 0903d777..f703288d 100644 --- a/example/lib/src/shared/state/general_provider.dart +++ b/example/lib/src/shared/state/general_provider.dart @@ -30,7 +30,10 @@ class GeneralProvider extends ChangeNotifier { } final Map currentStores = {}; - void changedCurrentStores() => notifyListeners(); + void changedCurrentStores() { + print(currentStores['(unspecified)']); + notifyListeners(); + } String _urlTemplate = sharedPrefs.getString(SharedPrefsKeys.urlTemplate.name) ?? From 2665573ab55517fae2975a9460737ae202e2bb8e Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 9 Sep 2024 21:36:05 +0100 Subject: [PATCH 53/97] Re-organized & refactored example app source Improved CONTRIBUTING Minor improvements to `FMTCImageProvider` --- CONTRIBUTING.md | 11 +-- example/lib/main.dart | 12 +-- .../configure_download.dart | 2 +- .../home/config_view/forms/side/side.dart | 81 ------------------- .../{home/home.dart => main/main.dart} | 42 +++++----- .../components}/bottom_sheet_wrapper.dart | 4 +- .../debugging_tile_builder.dart | 0 .../debugging_tile_builder/info_display.dart | 0 .../result_dialogs.dart | 0 .../region_selection/crosshairs.dart | 0 .../custom_polygon_snapping_indicator.dart | 0 .../region_selection/region_shape.dart | 0 .../additional_panes/additional_pane.dart | 0 .../adjust_zoom_lvls_pane.dart | 0 .../additional_panes/line_region_pane.dart | 0 .../additional_panes/slider_panel_base.dart | 0 .../side_panel/custom_slider_track_shape.dart | 0 .../region_selection/side_panel/parent.dart | 0 .../side_panel/primary_pane.dart | 0 .../side_panel/region_shape_button.dart | 0 .../{home => main}/map_view/map_view.dart | 0 .../map_view/state/map_provider.dart | 0 .../state/region_selection_provider.dart | 0 .../loading_behaviour_selector.dart} | 6 +- .../contents/home/components}/map/map.dart | 20 ++--- ...lumn_headers_and_inheritable_settings.dart | 4 +- .../components/export_stores/button.dart | 0 .../export_stores/name_input_dialog.dart | 0 .../export_stores/progress_dialog.dart | 0 .../stores/components/new_store_button.dart | 4 +- .../stores/components/no_stores.dart | 4 +- .../browse_store_strategy_selector.dart | 4 +- .../checkbox.dart | 0 .../dropdown.dart | 0 .../components/store_tiles/root_tile.dart | 0 .../components/store_tiles/store_tile.dart | 8 +- .../store_tiles/unspecified_tile.dart | 4 +- .../state/export_selection_provider.dart | 0 .../home/components}/stores/stores_list.dart | 2 +- .../home/home_view_bottom_sheet.dart} | 26 ++++-- .../contents/home/home_view_side.dart | 49 +++++++++++ .../layouts}/bottom_sheet/bottom_sheet.dart | 31 +++---- .../components/scrollable_provider.dart | 0 .../bottom_sheet/components/tab_header.dart | 0 .../bottom_sheet/components/toolbar.dart | 2 +- .../layouts/side/components/panel.dart | 21 +++++ .../secondary_view/layouts/side/side.dart | 47 +++++++++++ .../lib/src/shared/misc/circular_buffer.dart | 59 -------------- .../src/shared/state/general_provider.dart | 5 +- .../image_provider/image_provider.dart | 18 +++-- .../image_provider/internal_get_bytes.dart | 2 +- 51 files changed, 228 insertions(+), 240 deletions(-) delete mode 100644 example/lib/src/screens/home/config_view/forms/side/side.dart rename example/lib/src/screens/{home/home.dart => main/main.dart} (83%) rename example/lib/src/screens/{home/map_view => main/map_view/components}/bottom_sheet_wrapper.dart (96%) rename example/lib/src/screens/{home => main}/map_view/components/debugging_tile_builder/debugging_tile_builder.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/debugging_tile_builder/info_display.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/debugging_tile_builder/result_dialogs.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/crosshairs.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/custom_polygon_snapping_indicator.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/region_shape.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/side_panel/parent.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/side_panel/primary_pane.dart (100%) rename example/lib/src/screens/{home => main}/map_view/components/region_selection/side_panel/region_shape_button.dart (100%) rename example/lib/src/screens/{home => main}/map_view/map_view.dart (100%) rename example/lib/src/screens/{home => main}/map_view/state/map_provider.dart (100%) rename example/lib/src/screens/{home => main}/map_view/state/region_selection_provider.dart (100%) rename example/lib/src/screens/{home/config_view/panels/behaviour/behaviour.dart => main/secondary_view/contents/home/components/map/components/loading_behaviour_selector.dart} (94%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/map/map.dart (89%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/column_headers_and_inheritable_settings.dart (97%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/export_stores/button.dart (100%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/export_stores/name_input_dialog.dart (100%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/export_stores/progress_dialog.dart (100%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/new_store_button.dart (93%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/no_stores.dart (95%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart (96%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart (100%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart (100%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/store_tiles/root_tile.dart (100%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/store_tiles/store_tile.dart (98%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/components/store_tiles/unspecified_tile.dart (95%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/state/export_selection_provider.dart (100%) rename example/lib/src/screens/{home/config_view/panels => main/secondary_view/contents/home/components}/stores/stores_list.dart (98%) rename example/lib/src/screens/{home/config_view/forms/bottom_sheet/components/contents.dart => main/secondary_view/contents/home/home_view_bottom_sheet.dart} (69%) create mode 100644 example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart rename example/lib/src/screens/{home/config_view/forms => main/secondary_view/layouts}/bottom_sheet/bottom_sheet.dart (85%) rename example/lib/src/screens/{home/config_view/forms => main/secondary_view/layouts}/bottom_sheet/components/scrollable_provider.dart (100%) rename example/lib/src/screens/{home/config_view/forms => main/secondary_view/layouts}/bottom_sheet/components/tab_header.dart (100%) rename example/lib/src/screens/{home/config_view/forms => main/secondary_view/layouts}/bottom_sheet/components/toolbar.dart (96%) create mode 100644 example/lib/src/screens/main/secondary_view/layouts/side/components/panel.dart create mode 100644 example/lib/src/screens/main/secondary_view/layouts/side/side.dart delete mode 100644 example/lib/src/shared/misc/circular_buffer.dart diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a67550f..0da7ded8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,22 +2,23 @@ ## Reporting A Bug -FMTC is a large, platform-dependent package, and there's only one person running manual tests on it before releases, so there's a decent chance that a bug you've found is actually a bug. Reporting it is always appreciated! +I try to test FMTC on as many platforms as I have access to, using a combination of automated tests and manual tests through the example application. However, I only have access to Android and Windows devices. Due to the number of platform-dependent plugins this package uses, bugs are often only present on one platform, which is often iOS. However, they can of course occur on any platform if I've missed one. Reporting any bugs you find is always appreciated! Before reporting a bug, please: * Check if there is already an open or closed issue that is similar to yours -* Ensure that your Flutter environment is correctly installed & set-up -* Ensure that this package, 'flutter_map', and any modules are correctly installed & set-up +* Ensure that Flutter, this package, 'flutter_map', and any modules are correctly installed & set-up +* Follow the bug reporting issue template ## Contributing Code Contributors are always welcome, and support is always greatly appreciated! Before opening a Pull Request, however, please open a feature request or bug report to link the PR to. +Please note that all contributions may be dually licensed under an alternative proprietary license on a case-by-case basis, which grants no extra rights to contributors. + When submitting code, please: -* Keep code concise and in a similar style to surrounding code -* Document all public APIs in detail and with correct grammar +* Document all new public APIs * Use the included linting rules * Update the example application to appropriately consume any public API changes * Avoid incrementing this package's version number or changelog diff --git a/example/lib/main.dart b/example/lib/main.dart index 8f3839c4..6936c420 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,11 +7,11 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'src/screens/configure_download/configure_download.dart'; import 'src/screens/configure_download/state/configure_download_provider.dart'; import 'src/screens/download/download.dart'; -import 'src/screens/home/config_view/panels/stores/state/export_selection_provider.dart'; -import 'src/screens/home/home.dart'; -import 'src/screens/home/map_view/state/region_selection_provider.dart'; import 'src/screens/import/import.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; +import 'src/screens/main/main.dart'; +import 'src/screens/main/map_view/state/region_selection_provider.dart'; +import 'src/screens/main/secondary_view/contents/home/components/stores/state/export_selection_provider.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; import 'src/shared/state/general_provider.dart'; @@ -43,8 +43,8 @@ class _AppContainer extends StatelessWidget { Widget Function(BuildContext)? std, PageRoute Function(BuildContext, RouteSettings)? custom, })>{ - HomeScreen.route: ( - std: (BuildContext context) => const HomeScreen(), + MainScreen.route: ( + std: (BuildContext context) => const MainScreen(), custom: null, ), StoreEditorPopup.route: ( @@ -126,7 +126,7 @@ class _AppContainer extends StatelessWidget { title: 'FMTC Demo', restorationScopeId: 'FMTC Demo', theme: themeData, - initialRoute: HomeScreen.route, + initialRoute: MainScreen.route, onGenerateRoute: (settings) { final route = _routes[settings.name]!; if (route.custom != null) return route.custom!(context, settings); diff --git a/example/lib/src/screens/configure_download/configure_download.dart b/example/lib/src/screens/configure_download/configure_download.dart index 52209767..c1c9fd95 100644 --- a/example/lib/src/screens/configure_download/configure_download.dart +++ b/example/lib/src/screens/configure_download/configure_download.dart @@ -4,7 +4,7 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; import '../../shared/misc/exts/interleave.dart'; -import '../home/map_view/state/region_selection_provider.dart'; +import '../main/map_view/state/region_selection_provider.dart'; import 'components/numerical_input_row.dart'; import 'components/options_pane.dart'; import 'components/region_information.dart'; diff --git a/example/lib/src/screens/home/config_view/forms/side/side.dart b/example/lib/src/screens/home/config_view/forms/side/side.dart deleted file mode 100644 index 6abdef3b..00000000 --- a/example/lib/src/screens/home/config_view/forms/side/side.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../panels/map/map.dart'; -import '../../panels/stores/stores_list.dart'; - -class ConfigViewSide extends StatelessWidget { - const ConfigViewSide({ - super.key, - required this.selectedTab, - required this.constraints, - }); - - final int selectedTab; - final BoxConstraints constraints; - - @override - Widget build(BuildContext context) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeOut, - transitionBuilder: (child, animation) => SizeTransition( - axis: Axis.horizontal, - axisAlignment: 1, // Align right - sizeFactor: animation, - child: child, - ), - child: selectedTab == 0 - ? _ContentPanels(constraints) - : const SizedBox.shrink(), - ); -} - -class _ContentPanels extends StatelessWidget { - const _ContentPanels(this.constraints); - - final BoxConstraints constraints; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(right: 16, top: 16), - child: SizedBox( - width: (constraints.maxWidth / 3).clamp(440, 560), - child: Column( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.surface, - ), - padding: const EdgeInsets.all(16), - width: double.infinity, - child: const ConfigPanelMap(), - ), - const SizedBox(height: 16), - Expanded( - child: Container( - decoration: BoxDecoration( - borderRadius: - const BorderRadius.vertical(top: Radius.circular(16)), - color: Theme.of(context).colorScheme.surface, - ), - width: double.infinity, - height: double.infinity, - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.only(top: 16), - sliver: StoresList( - useCompactLayout: constraints.maxWidth / 3 < 500, - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - ], - ), - ), - ), - ], - ), - ), - ); -} diff --git a/example/lib/src/screens/home/home.dart b/example/lib/src/screens/main/main.dart similarity index 83% rename from example/lib/src/screens/home/home.dart rename to example/lib/src/screens/main/main.dart index 59aa7d18..10575722 100644 --- a/example/lib/src/screens/home/home.dart +++ b/example/lib/src/screens/main/main.dart @@ -1,20 +1,20 @@ import 'package:flutter/material.dart'; -import 'config_view/forms/bottom_sheet/bottom_sheet.dart'; -import 'config_view/forms/side/side.dart'; -import 'map_view/bottom_sheet_wrapper.dart'; +import 'map_view/components/bottom_sheet_wrapper.dart'; import 'map_view/map_view.dart'; +import 'secondary_view/layouts/bottom_sheet/bottom_sheet.dart'; +import 'secondary_view/layouts/side/side.dart'; -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); +class MainScreen extends StatefulWidget { + const MainScreen({super.key}); static const String route = '/'; @override - State createState() => _HomeScreenState(); + State createState() => _MainScreenState(); } -class _HomeScreenState extends State { +class _MainScreenState extends State { final bottomSheetOuterController = DraggableScrollableController(); int selectedTab = 0; @@ -39,8 +39,10 @@ class _HomeScreenState extends State { mode: mapMode, layoutDirection: layoutDirection, ), - bottomSheet: - ConfigViewBottomSheet(controller: bottomSheetOuterController), + bottomSheet: SecondaryViewBottomSheet( + selectedTab: selectedTab, + controller: bottomSheetOuterController, + ), bottomNavigationBar: NavigationBar( selectedIndex: selectedTab, destinations: const [ @@ -61,7 +63,8 @@ class _HomeScreenState extends State { ), ], onDestinationSelected: (i) { - if (i == 0) { + setState(() => selectedTab = i); + /*if (i == 0) { final requiresExpanding = bottomSheetOuterController.size < 0.3; @@ -96,24 +99,15 @@ class _HomeScreenState extends State { curve: Curves.easeIn, ), ); - } + }*/ }, ), ); } - return TweenAnimationBuilder( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - tween: Tween(begin: 0, end: selectedTab == 0 ? 0 : 1), - builder: (context, colorAnimation, child) => Scaffold( - backgroundColor: ColorTween( - begin: Theme.of(context).colorScheme.surfaceContainer, - end: Theme.of(context).scaffoldBackgroundColor, - ).lerp(colorAnimation), - body: child, - ), - child: LayoutBuilder( + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + body: LayoutBuilder( builder: (context, constraints) => Row( children: [ NavigationRail( @@ -151,7 +145,7 @@ class _HomeScreenState extends State { ), onDestinationSelected: (i) => setState(() => selectedTab = i), ), - ConfigViewSide( + SecondaryViewSide( selectedTab: selectedTab, constraints: constraints, ), diff --git a/example/lib/src/screens/home/map_view/bottom_sheet_wrapper.dart b/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart similarity index 96% rename from example/lib/src/screens/home/map_view/bottom_sheet_wrapper.dart rename to example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart index 37535977..64c23c42 100644 --- a/example/lib/src/screens/home/map_view/bottom_sheet_wrapper.dart +++ b/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../../../shared/components/delayed_frame_attached_dependent_builder.dart'; +import '../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; -import 'map_view.dart'; +import '../map_view.dart'; /// Wraps [MapView] with the necessary widgets to keep the map contents clear /// of the bottom sheet diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/debugging_tile_builder/debugging_tile_builder.dart rename to example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder/info_display.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/debugging_tile_builder/info_display.dart rename to example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart diff --git a/example/lib/src/screens/home/map_view/components/debugging_tile_builder/result_dialogs.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/result_dialogs.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/debugging_tile_builder/result_dialogs.dart rename to example/lib/src/screens/main/map_view/components/debugging_tile_builder/result_dialogs.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/crosshairs.dart b/example/lib/src/screens/main/map_view/components/region_selection/crosshairs.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/crosshairs.dart rename to example/lib/src/screens/main/map_view/components/region_selection/crosshairs.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/custom_polygon_snapping_indicator.dart b/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/custom_polygon_snapping_indicator.dart rename to example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/region_shape.dart b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/region_shape.dart rename to example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart rename to example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart rename to example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart rename to example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart rename to example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart rename to example/lib/src/screens/main/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/side_panel/parent.dart rename to example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/primary_pane.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/primary_pane.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/side_panel/primary_pane.dart rename to example/lib/src/screens/main/map_view/components/region_selection/side_panel/primary_pane.dart diff --git a/example/lib/src/screens/home/map_view/components/region_selection/side_panel/region_shape_button.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/region_shape_button.dart similarity index 100% rename from example/lib/src/screens/home/map_view/components/region_selection/side_panel/region_shape_button.dart rename to example/lib/src/screens/main/map_view/components/region_selection/side_panel/region_shape_button.dart diff --git a/example/lib/src/screens/home/map_view/map_view.dart b/example/lib/src/screens/main/map_view/map_view.dart similarity index 100% rename from example/lib/src/screens/home/map_view/map_view.dart rename to example/lib/src/screens/main/map_view/map_view.dart diff --git a/example/lib/src/screens/home/map_view/state/map_provider.dart b/example/lib/src/screens/main/map_view/state/map_provider.dart similarity index 100% rename from example/lib/src/screens/home/map_view/state/map_provider.dart rename to example/lib/src/screens/main/map_view/state/map_provider.dart diff --git a/example/lib/src/screens/home/map_view/state/region_selection_provider.dart b/example/lib/src/screens/main/map_view/state/region_selection_provider.dart similarity index 100% rename from example/lib/src/screens/home/map_view/state/region_selection_provider.dart rename to example/lib/src/screens/main/map_view/state/region_selection_provider.dart diff --git a/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/map/components/loading_behaviour_selector.dart similarity index 94% rename from example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/map/components/loading_behaviour_selector.dart index 0e8cdd48..c80ebe7d 100644 --- a/example/lib/src/screens/home/config_view/panels/behaviour/behaviour.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/map/components/loading_behaviour_selector.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../../../shared/state/general_provider.dart'; +import '../../../../../../../../shared/state/general_provider.dart'; -class ConfigPanelBehaviour extends StatelessWidget { - const ConfigPanelBehaviour({ +class LoadingBehaviourSelector extends StatelessWidget { + const LoadingBehaviourSelector({ super.key, }); diff --git a/example/lib/src/screens/home/config_view/panels/map/map.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/map/map.dart similarity index 89% rename from example/lib/src/screens/home/config_view/panels/map/map.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/map/map.dart index b697a066..bdd682fb 100644 --- a/example/lib/src/screens/home/config_view/panels/map/map.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/map/map.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../../../../shared/components/url_selector.dart'; -import '../../../../../shared/state/general_provider.dart'; -import '../../forms/bottom_sheet/components/scrollable_provider.dart'; -import '../behaviour/behaviour.dart'; +import '../../../../../../../shared/components/url_selector.dart'; +import '../../../../../../../shared/state/general_provider.dart'; +import '../../../../layouts/bottom_sheet/components/scrollable_provider.dart'; +import 'components/loading_behaviour_selector.dart'; -class ConfigPanelMap extends StatefulWidget { - const ConfigPanelMap({ +class MapConfigurator extends StatefulWidget { + const MapConfigurator({ super.key, this.bottomSheetOuterController, }); @@ -15,10 +15,10 @@ class ConfigPanelMap extends StatefulWidget { final DraggableScrollableController? bottomSheetOuterController; @override - State createState() => _ConfigPanelMapState(); + State createState() => _MapConfiguratorState(); } -class _ConfigPanelMapState extends State { +class _MapConfiguratorState extends State { double? _previousBottomSheetOuterHeight; double? _previousBottomSheetInnerHeight; @@ -26,8 +26,6 @@ class _ConfigPanelMapState extends State { Widget build(BuildContext context) => Column( mainAxisSize: MainAxisSize.min, children: [ - const ConfigPanelBehaviour(), - const SizedBox(height: 8), URLSelector( initialValue: context.select( (provider) => provider.urlTemplate, @@ -74,6 +72,8 @@ class _ConfigPanelMapState extends State { } : null, ), + const SizedBox(height: 12), + const LoadingBehaviourSelector(), const SizedBox(height: 6), Selector( selector: (context, provider) => provider.displayDebugOverlay, diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/column_headers_and_inheritable_settings.dart similarity index 97% rename from example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/column_headers_and_inheritable_settings.dart index e54bd832..681adc8f 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/column_headers_and_inheritable_settings.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/column_headers_and_inheritable_settings.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; -import '../../../../../../shared/state/general_provider.dart'; +import '../../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; +import '../../../../../../../../shared/state/general_provider.dart'; class ColumnHeadersAndInheritableSettings extends StatelessWidget { const ColumnHeadersAndInheritableSettings({ diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/button.dart similarity index 100% rename from example/lib/src/screens/home/config_view/panels/stores/components/export_stores/button.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/button.dart diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/name_input_dialog.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/name_input_dialog.dart similarity index 100% rename from example/lib/src/screens/home/config_view/panels/stores/components/export_stores/name_input_dialog.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/name_input_dialog.dart diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/export_stores/progress_dialog.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/progress_dialog.dart similarity index 100% rename from example/lib/src/screens/home/config_view/panels/stores/components/export_stores/progress_dialog.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/progress_dialog.dart diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/new_store_button.dart similarity index 93% rename from example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/new_store_button.dart index a8cf7367..d926a38d 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/new_store_button.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/new_store_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../../../../../import/import.dart'; -import '../../../../../store_editor/store_editor.dart'; +import '../../../../../../../import/import.dart'; +import '../../../../../../../store_editor/store_editor.dart'; class NewStoreButton extends StatelessWidget { const NewStoreButton({super.key}); diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/no_stores.dart similarity index 95% rename from example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/no_stores.dart index f2a8428e..33aa8206 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/no_stores.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/no_stores.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../../../../../import/import.dart'; -import '../../../../../store_editor/store_editor.dart'; +import '../../../../../../../import/import.dart'; +import '../../../../../../../store_editor/store_editor.dart'; class NoStores extends StatelessWidget { const NoStores({ diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart similarity index 96% rename from example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart index 2785eeb0..5e3e956f 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; -import '../../../../../../../../shared/state/general_provider.dart'; +import '../../../../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; +import '../../../../../../../../../../shared/state/general_provider.dart'; part 'checkbox.dart'; part 'dropdown.dart'; diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart similarity index 100% rename from example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart similarity index 100% rename from example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/root_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/root_tile.dart similarity index 100% rename from example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/root_tile.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/root_tile.dart diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/store_tile.dart similarity index 98% rename from example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/store_tile.dart index 2c4ecd7a..35414e59 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/store_tile.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/store_tile.dart @@ -4,10 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../../../../../shared/misc/exts/size_formatter.dart'; -import '../../../../../../../shared/misc/store_metadata_keys.dart'; -import '../../../../../../../shared/state/general_provider.dart'; -import '../../../../../../store_editor/store_editor.dart'; +import '../../../../../../../../../shared/misc/exts/size_formatter.dart'; +import '../../../../../../../../../shared/misc/store_metadata_keys.dart'; +import '../../../../../../../../../shared/state/general_provider.dart'; +import '../../../../../../../../store_editor/store_editor.dart'; import '../../state/export_selection_provider.dart'; import 'browse_store_strategy_selector/browse_store_strategy_selector.dart'; diff --git a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/unspecified_tile.dart similarity index 95% rename from example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/unspecified_tile.dart index b08e2424..9516194b 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/components/store_tiles/unspecified_tile.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/unspecified_tile.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; -import '../../../../../../../shared/state/general_provider.dart'; +import '../../../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; +import '../../../../../../../../../shared/state/general_provider.dart'; import 'browse_store_strategy_selector/browse_store_strategy_selector.dart'; class UnspecifiedTile extends StatefulWidget { diff --git a/example/lib/src/screens/home/config_view/panels/stores/state/export_selection_provider.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/state/export_selection_provider.dart similarity index 100% rename from example/lib/src/screens/home/config_view/panels/stores/state/export_selection_provider.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/state/export_selection_provider.dart diff --git a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/stores_list.dart similarity index 98% rename from example/lib/src/screens/home/config_view/panels/stores/stores_list.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores/stores_list.dart index 83bc0a63..c8ed8807 100644 --- a/example/lib/src/screens/home/config_view/panels/stores/stores_list.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores/stores_list.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../../../shared/misc/exts/size_formatter.dart'; +import '../../../../../../../shared/misc/exts/size_formatter.dart'; import 'components/column_headers_and_inheritable_settings.dart'; import 'components/export_stores/button.dart'; import 'components/new_store_button.dart'; diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart b/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart similarity index 69% rename from example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart rename to example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart index 965f7d1f..06aefb95 100644 --- a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/contents.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart @@ -1,15 +1,25 @@ -part of '../bottom_sheet.dart'; +import 'package:flutter/material.dart'; -class _ContentPanels extends StatefulWidget { - const _ContentPanels({required this.bottomSheetOuterController}); +import '../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; +import '../../layouts/bottom_sheet/bottom_sheet.dart'; +import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; +import '../../layouts/bottom_sheet/components/tab_header.dart'; +import 'components/map/map.dart'; +import 'components/stores/stores_list.dart'; + +class HomeViewBottomSheet extends StatefulWidget { + const HomeViewBottomSheet({ + super.key, + required this.bottomSheetOuterController, + }); final DraggableScrollableController bottomSheetOuterController; @override - State<_ContentPanels> createState() => _ContentPanelsState(); + State createState() => _ContentPanelsState(); } -class _ContentPanelsState extends State<_ContentPanels> { +class _ContentPanelsState extends State { @override Widget build(BuildContext context) { final screenTopPadding = @@ -35,13 +45,13 @@ class _ContentPanelsState extends State<_ContentPanels> { final oldMin = maxHeight - screenTopPadding; const maxTopPadding = 0.0; - const minTopPadding = ConfigViewBottomSheet.topPadding - 8; + const minTopPadding = SecondaryViewBottomSheet.topPadding - 8; final double topPaddingHeight = ((((oldValue - oldMin) * (maxTopPadding - minTopPadding)) / (oldMax - oldMin)) + minTopPadding) - .clamp(0.0, ConfigViewBottomSheet.topPadding - 8); + .clamp(0.0, SecondaryViewBottomSheet.topPadding - 8); return SizedBox(height: topPaddingHeight); }, @@ -54,7 +64,7 @@ class _ContentPanelsState extends State<_ContentPanels> { SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverToBoxAdapter( - child: ConfigPanelMap( + child: MapConfigurator( bottomSheetOuterController: widget.bottomSheetOuterController, ), ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart new file mode 100644 index 00000000..87dc1f3c --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../../layouts/side/components/panel.dart'; +import 'components/map/map.dart'; +import 'components/stores/stores_list.dart'; + +class HomeViewSide extends StatefulWidget { + const HomeViewSide({ + super.key, + required this.constraints, + }); + + final BoxConstraints constraints; + + @override + State createState() => _HomeViewSideState(); +} + +class _HomeViewSideState extends State { + @override + Widget build(BuildContext context) => Column( + children: [ + const SideViewPanel(child: MapConfigurator()), + const SizedBox(height: 16), + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + color: Theme.of(context).colorScheme.surface, + ), + width: double.infinity, + height: double.infinity, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(top: 16), + sliver: StoresList( + useCompactLayout: widget.constraints.maxWidth / 3 < 500, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ), + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/bottom_sheet.dart similarity index 85% rename from example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart rename to example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/bottom_sheet.dart index 9b3e1ba8..98ad9a68 100644 --- a/example/lib/src/screens/home/config_view/forms/bottom_sheet/bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/bottom_sheet.dart @@ -1,28 +1,27 @@ import 'package:flutter/material.dart'; import '../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; -import '../../panels/map/map.dart'; -import '../../panels/stores/stores_list.dart'; +import '../../contents/home/home_view_bottom_sheet.dart'; import 'components/scrollable_provider.dart'; -import 'components/tab_header.dart'; -part 'components/contents.dart'; - -class ConfigViewBottomSheet extends StatefulWidget { - const ConfigViewBottomSheet({ +class SecondaryViewBottomSheet extends StatefulWidget { + const SecondaryViewBottomSheet({ super.key, + required this.selectedTab, required this.controller, }); + final int selectedTab; final DraggableScrollableController controller; static const topPadding = kMinInteractiveDimension / 1.5; @override - State createState() => _ConfigViewBottomSheetState(); + State createState() => + _SecondaryViewBottomSheetState(); } -class _ConfigViewBottomSheetState extends State { +class _SecondaryViewBottomSheetState extends State { @override Widget build(BuildContext context) { final screenTopPadding = @@ -101,9 +100,11 @@ class _ConfigViewBottomSheetState extends State { innerScrollController: innerController, child: SizedBox( width: double.infinity, - child: _ContentPanels( - bottomSheetOuterController: widget.controller, - ), + child: widget.selectedTab == 0 + ? HomeViewBottomSheet( + bottomSheetOuterController: widget.controller, + ) + : const Placeholder(), ), ), IgnorePointer( @@ -114,14 +115,14 @@ class _ConfigViewBottomSheetState extends State { return const SizedBox.shrink(); } - final calcHeight = ConfigViewBottomSheet.topPadding - + final calcHeight = SecondaryViewBottomSheet.topPadding - (screenTopPadding - constraints.maxHeight + widget.controller.pixels); return SizedBox( - height: - calcHeight.clamp(0, ConfigViewBottomSheet.topPadding), + height: calcHeight.clamp( + 0, SecondaryViewBottomSheet.topPadding), width: constraints.maxWidth, child: Semantics( label: MaterialLocalizations.of(context) diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/scrollable_provider.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart similarity index 100% rename from example/lib/src/screens/home/config_view/forms/bottom_sheet/components/scrollable_provider.dart rename to example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/tab_header.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/tab_header.dart similarity index 100% rename from example/lib/src/screens/home/config_view/forms/bottom_sheet/components/tab_header.dart rename to example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/tab_header.dart diff --git a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/toolbar.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/toolbar.dart similarity index 96% rename from example/lib/src/screens/home/config_view/forms/bottom_sheet/components/toolbar.dart rename to example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/toolbar.dart index 5c72b65f..bd2e7427 100644 --- a/example/lib/src/screens/home/config_view/forms/bottom_sheet/components/toolbar.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/toolbar.dart @@ -21,7 +21,7 @@ class BottomSheetToolbar extends StatelessWidget { AnimatedBuilder( animation: bottomSheetOuterController, builder: (context, child) => SizedBox( - height: ConfigViewBottomSheet.topPadding - + height: SecondaryViewBottomSheet.topPadding - _calcVisibility(bottomSheetOuterController.size, 16), ), ), diff --git a/example/lib/src/screens/main/secondary_view/layouts/side/components/panel.dart b/example/lib/src/screens/main/secondary_view/layouts/side/components/panel.dart new file mode 100644 index 00000000..d6dd76b6 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/layouts/side/components/panel.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class SideViewPanel extends StatelessWidget { + const SideViewPanel({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(16), + width: double.infinity, + child: child, + ); +} 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 new file mode 100644 index 00000000..a7d69323 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/layouts/side/side.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../../contents/home/home_view_side.dart'; + +class SecondaryViewSide extends StatelessWidget { + const SecondaryViewSide({ + super.key, + required this.selectedTab, + required this.constraints, + }); + + final int selectedTab; + final BoxConstraints constraints; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(right: 16, top: 16), + child: SizedBox( + width: (constraints.maxWidth / 3).clamp(440, 560), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + transitionBuilder: (child, animation) => ScaleTransition( + scale: Tween(begin: 0.5, end: 1).animate( + CurvedAnimation( + parent: animation, + curve: Curves.fastOutSlowIn, + ), + ), + child: FadeTransition( + opacity: Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: animation, + curve: Curves.fastOutSlowIn, + ), + ), + child: child, + ), + ), + child: selectedTab == 0 + ? HomeViewSide(constraints: constraints) + : const Placeholder(), + ), + ), + ); +} diff --git a/example/lib/src/shared/misc/circular_buffer.dart b/example/lib/src/shared/misc/circular_buffer.dart deleted file mode 100644 index 93cda718..00000000 --- a/example/lib/src/shared/misc/circular_buffer.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:collection'; - -/// A list with a fixed length ([capacity]) that continuously overwrites the -/// oldest element as necessary -final class CircularBuffer with ListMixin { - CircularBuffer({required this.capacity}); - - /// Maximum number of elements - final int capacity; - - final _buffer = []; - int _ptr = 0; - - /// Whether the queue capacity has been entirely consumed - bool get isFilled => _buffer.length == capacity; - - @override - void add(T element) { - if (!isFilled) return _buffer.add(element); - _buffer[_ptr] = element; - _ptr++; - if (_ptr == capacity) _ptr = 0; - } - - int _calcActualIndex(int i) => (_ptr + i) % _buffer.length; - - @override - T operator [](int index) { - if (index < 0 || index >= _buffer.length) { - throw RangeError.index(index, this); - } - return _buffer[_calcActualIndex(index)]; - } - - @override - void operator []=(int index, T value) { - if (index < 0 || index >= _buffer.length) { - throw RangeError.index(index, this); - } - _buffer[_calcActualIndex(index)] = value; - } - - /// Number of consumed elements of queue - @override - int get length => _buffer.length; - - /// It is forbidden to modify the length of a `CircularQueue` - @override - set length(int newLength) { - throw UnsupportedError('Unable to resize a `CircularQueue`'); - } - - /// Empties the queue - @override - void clear() { - _ptr = 0; - _buffer.clear(); - } -} diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart index f703288d..0903d777 100644 --- a/example/lib/src/shared/state/general_provider.dart +++ b/example/lib/src/shared/state/general_provider.dart @@ -30,10 +30,7 @@ class GeneralProvider extends ChangeNotifier { } final Map currentStores = {}; - void changedCurrentStores() { - print(currentStores['(unspecified)']); - notifyListeners(); - } + void changedCurrentStores() => notifyListeners(); String _urlTemplate = sharedPrefs.getString(SharedPrefsKeys.urlTemplate.name) ?? diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index 17036ba5..6b67557a 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -54,11 +54,19 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { ).then(ImmutableBuffer.fromUint8List).then((v) => decode(v)), scale: 1, debugLabel: coords.toString(), - informationCollector: () => [ - DiagnosticsProperty('Store names', provider.storeNames), - DiagnosticsProperty('Tile coordinates', coords), - DiagnosticsProperty('Current provider', key), - ], + informationCollector: () { + final tileUrl = provider.getTileUrl(coords, options); + + return [ + DiagnosticsProperty('Store names', provider.storeNames), + DiagnosticsProperty('Tile coordinates', coords), + DiagnosticsProperty('Tile URL', tileUrl), + DiagnosticsProperty( + 'Tile storage-suitable UID', + provider.urlTransformer(tileUrl), + ), + ]; + }, ); /// {@macro fmtc.imageProvider.getBytes} diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index aa4d4619..47097393 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -115,7 +115,7 @@ Future _internalGetBytes({ try { if (provider.fakeNetworkDisconnect) { throw const SocketException( - 'Faked `SocketException` due to `fakeNetworkDisconnect`', + 'Faked `SocketException` due to `fakeNetworkDisconnect` flag set', ); } response = await provider.httpClient From 3fe9e638e0c689aa7f0fafe166faa96820ddfab2 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 10 Sep 2024 10:14:27 +0100 Subject: [PATCH 54/97] Prepare example application for new region selection UI --- example/lib/main.dart | 4 +- .../configure_download.dart | 2 +- .../components/bottom_sheet_wrapper.dart | 2 +- .../custom_polygon_snapping_indicator.dart | 2 +- .../region_selection/region_shape.dart | 2 +- .../region_selection/side_panel/parent.dart | 2 +- .../src/screens/main/map_view/map_view.dart | 2 +- .../main/map_view/state/map_provider.dart | 28 ---- .../contents/home/components/map/map.dart | 131 ----------------- .../loading_behaviour_selector.dart | 0 .../map_configurator/map_configurator.dart | 133 ++++++++++++++++++ ...lumn_headers_and_inheritable_settings.dart | 0 .../components/export_stores/button.dart | 0 .../export_stores/name_input_dialog.dart | 0 .../export_stores/progress_dialog.dart | 0 .../components/new_store_button.dart | 0 .../components/no_stores.dart | 0 .../browse_store_strategy_selector.dart | 0 .../checkbox.dart | 0 .../dropdown.dart | 0 .../components/store_tiles/root_tile.dart | 0 .../components/store_tiles/store_tile.dart | 0 .../store_tiles/unspecified_tile.dart | 0 .../state/export_selection_provider.dart | 0 .../{stores => stores_list}/stores_list.dart | 0 .../contents/home/home_view_bottom_sheet.dart | 90 +++--------- .../contents/home/home_view_side.dart | 4 +- .../components/shape_selector.dart | 15 ++ .../region_selection_view_bottom_sheet.dart | 30 ++++ .../region_selection_view_side.dart | 15 ++ .../layouts/bottom_sheet/bottom_sheet.dart | 18 ++- ...ayed_frame_attached_dependent_builder.dart | 0 .../components/scrollable_provider.dart | 21 ++- .../bottom_sheet/components/toolbar.dart | 54 ------- .../utils/bottom_sheet_top_spacer.dart | 47 +++++++ .../{components => utils}/tab_header.dart | 45 +++--- .../secondary_view/layouts/side/side.dart | 9 +- .../shared/components/build_attribution.dart | 35 ----- .../state/region_selection_provider.dart | 0 39 files changed, 334 insertions(+), 357 deletions(-) delete mode 100644 example/lib/src/screens/main/map_view/state/map_provider.dart delete mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/map/map.dart rename example/lib/src/screens/main/secondary_view/contents/home/components/{map => map_configurator}/components/loading_behaviour_selector.dart (100%) create mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/map_configurator.dart rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/column_headers_and_inheritable_settings.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/export_stores/button.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/export_stores/name_input_dialog.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/export_stores/progress_dialog.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/new_store_button.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/no_stores.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/store_tiles/browse_store_strategy_selector/checkbox.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/store_tiles/browse_store_strategy_selector/dropdown.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/store_tiles/root_tile.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/store_tiles/store_tile.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/components/store_tiles/unspecified_tile.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/state/export_selection_provider.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/{stores => stores_list}/stores_list.dart (100%) create mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart rename example/lib/src/{shared => screens/main/secondary_view/layouts/bottom_sheet}/components/delayed_frame_attached_dependent_builder.dart (100%) delete mode 100644 example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/toolbar.dart create mode 100644 example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart rename example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/{components => utils}/tab_header.dart (76%) delete mode 100644 example/lib/src/shared/components/build_attribution.dart rename example/lib/src/{screens/main/map_view => shared}/state/region_selection_provider.dart (100%) diff --git a/example/lib/main.dart b/example/lib/main.dart index 6936c420..28001296 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,8 +10,8 @@ import 'src/screens/download/download.dart'; import 'src/screens/import/import.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/main/main.dart'; -import 'src/screens/main/map_view/state/region_selection_provider.dart'; -import 'src/screens/main/secondary_view/contents/home/components/stores/state/export_selection_provider.dart'; +import 'src/shared/state/region_selection_provider.dart'; +import 'src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; import 'src/shared/state/general_provider.dart'; diff --git a/example/lib/src/screens/configure_download/configure_download.dart b/example/lib/src/screens/configure_download/configure_download.dart index c1c9fd95..44892d76 100644 --- a/example/lib/src/screens/configure_download/configure_download.dart +++ b/example/lib/src/screens/configure_download/configure_download.dart @@ -4,7 +4,7 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; import '../../shared/misc/exts/interleave.dart'; -import '../main/map_view/state/region_selection_provider.dart'; +import '../../shared/state/region_selection_provider.dart'; import 'components/numerical_input_row.dart'; import 'components/options_pane.dart'; import 'components/region_information.dart'; diff --git a/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart b/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart index 64c23c42..4d841a2f 100644 --- a/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart +++ b/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; +import '../../secondary_view/layouts/bottom_sheet/components/delayed_frame_attached_dependent_builder.dart'; import '../map_view.dart'; diff --git a/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart b/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart index 6337416a..75f2f8e2 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart @@ -3,7 +3,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import '../../state/region_selection_provider.dart'; +import '../../../../../shared/state/region_selection_provider.dart'; class CustomPolygonSnappingIndicator extends StatelessWidget { const CustomPolygonSnappingIndicator({ diff --git a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart index 070bd836..47a52f3d 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart @@ -4,7 +4,7 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import '../../state/region_selection_provider.dart'; +import '../../../../../shared/state/region_selection_provider.dart'; class RegionShape extends StatelessWidget { const RegionShape({ diff --git a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart index 04a62118..b3ac952c 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart @@ -8,7 +8,7 @@ import 'package:provider/provider.dart'; import '../../../../../../shared/misc/exts/interleave.dart'; import '../../../../../configure_download/configure_download.dart'; -import '../../../state/region_selection_provider.dart'; +import '../../../../../../shared/state/region_selection_provider.dart'; part 'additional_panes/additional_pane.dart'; part 'additional_panes/adjust_zoom_lvls_pane.dart'; 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 a643796a..2771853e 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -18,7 +18,7 @@ import 'components/region_selection/crosshairs.dart'; import 'components/region_selection/custom_polygon_snapping_indicator.dart'; import 'components/region_selection/region_shape.dart'; import 'components/region_selection/side_panel/parent.dart'; -import 'state/region_selection_provider.dart'; +import '../../../shared/state/region_selection_provider.dart'; enum MapViewMode { standard, diff --git a/example/lib/src/screens/main/map_view/state/map_provider.dart b/example/lib/src/screens/main/map_view/state/map_provider.dart deleted file mode 100644 index be4445f8..00000000 --- a/example/lib/src/screens/main/map_view/state/map_provider.dart +++ /dev/null @@ -1,28 +0,0 @@ -/*import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; - -typedef AnimateToSignature = Future Function({ - LatLng? dest, - double? zoom, - Offset offset, - double? rotation, - Curve? curve, - String? customId, -}); - -class MapProvider extends ChangeNotifier { - MapController _mapController = MapController(); - MapController get mapController => _mapController; - set mapController(MapController newController) { - _mapController = newController; - notifyListeners(); - } - - late AnimateToSignature? _animateTo; - AnimateToSignature get animateTo => _animateTo!; - set animateTo(AnimateToSignature newMethod) { - _animateTo = newMethod; - notifyListeners(); - } -}*/ diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/map/map.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/map/map.dart deleted file mode 100644 index bdd682fb..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/map/map.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../../../shared/components/url_selector.dart'; -import '../../../../../../../shared/state/general_provider.dart'; -import '../../../../layouts/bottom_sheet/components/scrollable_provider.dart'; -import 'components/loading_behaviour_selector.dart'; - -class MapConfigurator extends StatefulWidget { - const MapConfigurator({ - super.key, - this.bottomSheetOuterController, - }); - - final DraggableScrollableController? bottomSheetOuterController; - - @override - State createState() => _MapConfiguratorState(); -} - -class _MapConfiguratorState extends State { - double? _previousBottomSheetOuterHeight; - double? _previousBottomSheetInnerHeight; - - @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - URLSelector( - initialValue: context.select( - (provider) => provider.urlTemplate, - ), - onSelected: (urlTemplate) => - context.read().urlTemplate = urlTemplate, - onFocus: widget.bottomSheetOuterController != null - ? () { - _previousBottomSheetOuterHeight = - widget.bottomSheetOuterController!.size; - _previousBottomSheetInnerHeight = - BottomSheetScrollableProvider.innerScrollControllerOf( - context, - ).offset; - - widget.bottomSheetOuterController!.animateTo( - 1, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - BottomSheetScrollableProvider.innerScrollControllerOf( - context, - ).animateTo( - 1, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - } - : null, - onUnfocus: widget.bottomSheetOuterController != null - ? () { - widget.bottomSheetOuterController!.animateTo( - _previousBottomSheetOuterHeight ?? 0.3, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - BottomSheetScrollableProvider.innerScrollControllerOf( - context, - ).animateTo( - _previousBottomSheetInnerHeight ?? 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - } - : null, - ), - const SizedBox(height: 12), - const LoadingBehaviourSelector(), - const SizedBox(height: 6), - Selector( - selector: (context, provider) => provider.displayDebugOverlay, - builder: (context, displayDebugOverlay, _) => Row( - children: [ - const SizedBox(width: 8), - const Expanded(child: Text('Display debug/info tile overlay')), - const SizedBox(width: 12), - Switch.adaptive( - value: displayDebugOverlay, - onChanged: (value) => context - .read() - .displayDebugOverlay = value, - thumbIcon: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.selected) - ? const Icon(Icons.layers) - : const Icon(Icons.layers_clear), - ), - ), - const SizedBox(width: 8), - ], - ), - ), - Selector( - selector: (context, provider) => provider.fakeNetworkDisconnect, - builder: (context, fakeNetworkDisconnect, _) => Row( - children: [ - const SizedBox(width: 8), - const Expanded( - child: Text('Fake network disconnect (when FMTC in use)'), - ), - const SizedBox(width: 12), - Switch.adaptive( - value: fakeNetworkDisconnect, - onChanged: (value) => context - .read() - .fakeNetworkDisconnect = value, - thumbIcon: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.selected) - ? const Icon(Icons.cloud_off) - : const Icon(Icons.cloud), - ), - trackColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.selected) - ? Colors.orange - : null, - ), - ), - const SizedBox(width: 8), - ], - ), - ), - ], - ); -} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/map/components/loading_behaviour_selector.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/components/loading_behaviour_selector.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/map/components/loading_behaviour_selector.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/components/loading_behaviour_selector.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/map_configurator.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/map_configurator.dart new file mode 100644 index 00000000..54112647 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/map_configurator.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../shared/components/url_selector.dart'; +import '../../../../../../../shared/state/general_provider.dart'; +import '../../../../layouts/bottom_sheet/components/scrollable_provider.dart'; +import 'components/loading_behaviour_selector.dart'; + +class MapConfigurator extends StatefulWidget { + const MapConfigurator({super.key}); + + @override + State createState() => _MapConfiguratorState(); +} + +class _MapConfiguratorState extends State { + double? _previousBottomSheetOuterHeight; + double? _previousBottomSheetInnerHeight; + + @override + Widget build(BuildContext context) { + final bottomSheetOuterController = + BottomSheetScrollableProvider.maybeOuterScrollControllerOf(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + URLSelector( + initialValue: context.select( + (provider) => provider.urlTemplate, + ), + onSelected: (urlTemplate) => + context.read().urlTemplate = urlTemplate, + onFocus: bottomSheetOuterController != null + ? () { + final innerController = + BottomSheetScrollableProvider.innerScrollControllerOf( + context, + ); + + _previousBottomSheetOuterHeight = + bottomSheetOuterController.size; + _previousBottomSheetInnerHeight = innerController.offset; + + bottomSheetOuterController.animateTo( + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + innerController.animateTo( + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + } + : null, + onUnfocus: bottomSheetOuterController != null + ? () { + final innerController = + BottomSheetScrollableProvider.innerScrollControllerOf( + context, + ); + + bottomSheetOuterController.animateTo( + _previousBottomSheetOuterHeight ?? 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + innerController.animateTo( + _previousBottomSheetInnerHeight ?? 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + } + : null, + ), + const SizedBox(height: 12), + const LoadingBehaviourSelector(), + const SizedBox(height: 6), + Selector( + selector: (context, provider) => provider.displayDebugOverlay, + builder: (context, displayDebugOverlay, _) => Row( + children: [ + const SizedBox(width: 8), + const Expanded(child: Text('Display debug/info tile overlay')), + const SizedBox(width: 12), + Switch.adaptive( + value: displayDebugOverlay, + onChanged: (value) => + context.read().displayDebugOverlay = value, + thumbIcon: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? const Icon(Icons.layers) + : const Icon(Icons.layers_clear), + ), + ), + const SizedBox(width: 8), + ], + ), + ), + Selector( + selector: (context, provider) => provider.fakeNetworkDisconnect, + builder: (context, fakeNetworkDisconnect, _) => Row( + children: [ + const SizedBox(width: 8), + const Expanded( + child: Text('Fake network disconnect (when FMTC in use)'), + ), + const SizedBox(width: 12), + Switch.adaptive( + value: fakeNetworkDisconnect, + onChanged: (value) => context + .read() + .fakeNetworkDisconnect = value, + thumbIcon: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? const Icon(Icons.cloud_off) + : const Icon(Icons.cloud), + ), + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? Colors.orange + : null, + ), + ), + const SizedBox(width: 8), + ], + ), + ), + ], + ); + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/column_headers_and_inheritable_settings.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/column_headers_and_inheritable_settings.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/button.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/name_input_dialog.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/name_input_dialog.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/name_input_dialog.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/name_input_dialog.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/progress_dialog.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/progress_dialog.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/export_stores/progress_dialog.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/progress_dialog.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/new_store_button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/new_store_button.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/no_stores.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/no_stores.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/checkbox.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/checkbox.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/checkbox.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/dropdown.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/browse_store_strategy_selector/dropdown.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/dropdown.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/root_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/root_tile.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/root_tile.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/root_tile.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/store_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/store_tile.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/unspecified_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/unspecified_tile.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/components/store_tiles/unspecified_tile.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/unspecified_tile.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/state/export_selection_provider.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/state/export_selection_provider.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores/stores_list.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores/stores_list.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart index 06aefb95..febc3cf7 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart @@ -1,79 +1,35 @@ import 'package:flutter/material.dart'; -import '../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; -import '../../layouts/bottom_sheet/bottom_sheet.dart'; import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; -import '../../layouts/bottom_sheet/components/tab_header.dart'; -import 'components/map/map.dart'; -import 'components/stores/stores_list.dart'; +import '../../layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart'; +import '../../layouts/bottom_sheet/utils/tab_header.dart'; +import 'components/map_configurator/map_configurator.dart'; +import 'components/stores_list/stores_list.dart'; class HomeViewBottomSheet extends StatefulWidget { - const HomeViewBottomSheet({ - super.key, - required this.bottomSheetOuterController, - }); - - final DraggableScrollableController bottomSheetOuterController; + const HomeViewBottomSheet({super.key}); @override - State createState() => _ContentPanelsState(); + State createState() => _HomeViewBottomSheetState(); } -class _ContentPanelsState extends State { +class _HomeViewBottomSheetState extends State { @override - Widget build(BuildContext context) { - final screenTopPadding = - MediaQueryData.fromView(View.of(context)).padding.top; - - return CustomScrollView( - controller: - BottomSheetScrollableProvider.innerScrollControllerOf(context), - slivers: [ - SliverToBoxAdapter( - child: DelayedControllerAttachmentBuilder( - listenable: widget.bottomSheetOuterController, - builder: (context, _) { - if (!widget.bottomSheetOuterController.isAttached) { - return const SizedBox.shrink(); - } - - final maxHeight = - widget.bottomSheetOuterController.sizeToPixels(1); - - final oldValue = widget.bottomSheetOuterController.pixels; - final oldMax = maxHeight; - final oldMin = maxHeight - screenTopPadding; - - const maxTopPadding = 0.0; - const minTopPadding = SecondaryViewBottomSheet.topPadding - 8; - - final double topPaddingHeight = - ((((oldValue - oldMin) * (maxTopPadding - minTopPadding)) / - (oldMax - oldMin)) + - minTopPadding) - .clamp(0.0, SecondaryViewBottomSheet.topPadding - 8); - - return SizedBox(height: topPaddingHeight); - }, - ), - ), - TabHeader( - bottomSheetOuterController: widget.bottomSheetOuterController, - ), - const SliverToBoxAdapter(child: SizedBox(height: 6)), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverToBoxAdapter( - child: MapConfigurator( - bottomSheetOuterController: widget.bottomSheetOuterController, - ), + Widget build(BuildContext context) => CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + slivers: const [ + BottomSheetTopSpacer(), + TabHeader(title: 'Stores & Config'), + SliverToBoxAdapter(child: SizedBox(height: 6)), + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter(child: MapConfigurator()), ), - ), - const SliverToBoxAdapter(child: Divider(height: 24)), - const SliverToBoxAdapter(child: SizedBox(height: 6)), - const StoresList(useCompactLayout: true), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - ], - ); - } + SliverToBoxAdapter(child: Divider(height: 24)), + SliverToBoxAdapter(child: SizedBox(height: 6)), + StoresList(useCompactLayout: true), + SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ); } diff --git a/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart index 87dc1f3c..e9353ee2 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import '../../layouts/side/components/panel.dart'; -import 'components/map/map.dart'; -import 'components/stores/stores_list.dart'; +import 'components/map_configurator/map_configurator.dart'; +import 'components/stores_list/stores_list.dart'; class HomeViewSide extends StatefulWidget { const HomeViewSide({ diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector.dart new file mode 100644 index 00000000..e91a4b52 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class ShapeSelector extends StatefulWidget { + const ShapeSelector({super.key}); + + @override + State createState() => _ShapeSelectorState(); +} + +class _ShapeSelectorState extends State { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart new file mode 100644 index 00000000..1a997565 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; +import '../../layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart'; +import '../../layouts/bottom_sheet/utils/tab_header.dart'; +import 'components/shape_selector.dart'; + +class RegionSelectionViewBottomSheet extends StatelessWidget { + const RegionSelectionViewBottomSheet({super.key}); + + @override + Widget build(BuildContext context) => CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + slivers: const [ + BottomSheetTopSpacer(), + TabHeader(title: 'Download Selection'), + SliverToBoxAdapter(child: SizedBox(height: 6)), + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: ShapeSelector(), + ), + ), + SliverToBoxAdapter(child: Divider(height: 24)), + SliverToBoxAdapter(child: SizedBox(height: 6)), + SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart new file mode 100644 index 00000000..90fcf27d --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import '../../layouts/side/components/panel.dart'; +import 'components/shape_selector.dart'; + +class RegionSelectionViewSide extends StatelessWidget { + const RegionSelectionViewSide({super.key}); + + @override + Widget build(BuildContext context) => const Column( + children: [ + SideViewPanel(child: ShapeSelector()), + ], + ); +} 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 98ad9a68..c88acaba 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 @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import '../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; import '../../contents/home/home_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'; class SecondaryViewBottomSheet extends StatefulWidget { @@ -98,13 +99,14 @@ class _SecondaryViewBottomSheetState extends State { // injection, as that may not be possible in future BottomSheetScrollableProvider( innerScrollController: innerController, + outerScrollController: widget.controller, child: SizedBox( width: double.infinity, - child: widget.selectedTab == 0 - ? HomeViewBottomSheet( - bottomSheetOuterController: widget.controller, - ) - : const Placeholder(), + child: switch (widget.selectedTab) { + 0 => const HomeViewBottomSheet(), + 1 => const RegionSelectionViewBottomSheet(), + _ => Placeholder(key: ValueKey(widget.selectedTab)), + }, ), ), IgnorePointer( @@ -122,7 +124,9 @@ class _SecondaryViewBottomSheetState extends State { return SizedBox( height: calcHeight.clamp( - 0, SecondaryViewBottomSheet.topPadding), + 0, + SecondaryViewBottomSheet.topPadding, + ), width: constraints.maxWidth, child: Semantics( label: MaterialLocalizations.of(context) diff --git a/example/lib/src/shared/components/delayed_frame_attached_dependent_builder.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/delayed_frame_attached_dependent_builder.dart similarity index 100% rename from example/lib/src/shared/components/delayed_frame_attached_dependent_builder.dart rename to example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/delayed_frame_attached_dependent_builder.dart diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart index b6498b62..34eceafe 100644 --- a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart @@ -5,16 +5,33 @@ class BottomSheetScrollableProvider extends InheritedWidget { super.key, required super.child, required this.innerScrollController, + required this.outerScrollController, }); final ScrollController innerScrollController; + final DraggableScrollableController outerScrollController; Widget build(BuildContext context) => child; + static ScrollController? maybeInnerScrollControllerOf(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.innerScrollController; + static ScrollController innerScrollControllerOf(BuildContext context) => + maybeInnerScrollControllerOf(context)!; + + static DraggableScrollableController? maybeOuterScrollControllerOf( + BuildContext context, + ) => context - .dependOnInheritedWidgetOfExactType()! - .innerScrollController; + .dependOnInheritedWidgetOfExactType() + ?.outerScrollController; + + static DraggableScrollableController outerScrollControllerOf( + BuildContext context, + ) => + maybeOuterScrollControllerOf(context)!; @override bool updateShouldNotify(covariant BottomSheetScrollableProvider oldWidget) => diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/toolbar.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/toolbar.dart deleted file mode 100644 index bd2e7427..00000000 --- a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/toolbar.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../bottom_sheet.dart'; - -class BottomSheetToolbar extends StatelessWidget { - const BottomSheetToolbar({ - super.key, - required this.bottomSheetOuterController, - required this.action, - }); - - final DraggableScrollableController bottomSheetOuterController; - final Widget action; - - double _calcVisibility(double size, double newMax) => - ((((size - 0.3) * (newMax - 0)) / (0.85 - 0.3)) + 0).clamp(0, newMax); - - @override - Widget build(BuildContext context) => Column( - children: [ - AnimatedBuilder( - animation: bottomSheetOuterController, - builder: (context, child) => SizedBox( - height: SecondaryViewBottomSheet.topPadding - - _calcVisibility(bottomSheetOuterController.size, 16), - ), - ), - AnimatedBuilder( - animation: bottomSheetOuterController, - builder: (context, child) { - final size = bottomSheetOuterController.size; - - return Padding( - padding: EdgeInsets.only(bottom: _calcVisibility(size, 8)), - child: Opacity( - opacity: _calcVisibility(size, 1), - child: SizedBox( - height: _calcVisibility(size, 50), - child: child, - ), - ), - ); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(), - action, - ], - ), - ), - ], - ); -} diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart new file mode 100644 index 00000000..1e9fd590 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../bottom_sheet.dart'; +import '../components/delayed_frame_attached_dependent_builder.dart'; +import '../components/scrollable_provider.dart'; + +class BottomSheetTopSpacer extends StatelessWidget { + const BottomSheetTopSpacer({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final screenTopPadding = + MediaQueryData.fromView(View.of(context)).padding.top; + final outerScrollController = + BottomSheetScrollableProvider.outerScrollControllerOf(context); + + return SliverToBoxAdapter( + child: DelayedControllerAttachmentBuilder( + listenable: outerScrollController, + builder: (context, _) { + if (!outerScrollController.isAttached) { + return const SizedBox.shrink(); + } + + final maxHeight = outerScrollController.sizeToPixels(1); + + final oldValue = outerScrollController.pixels; + final oldMax = maxHeight; + final oldMin = maxHeight - screenTopPadding; + + const maxTopPadding = 0.0; + const minTopPadding = SecondaryViewBottomSheet.topPadding - 8; + + final double topPaddingHeight = + ((((oldValue - oldMin) * (maxTopPadding - minTopPadding)) / + (oldMax - oldMin)) + + minTopPadding) + .clamp(0.0, SecondaryViewBottomSheet.topPadding - 8); + + return SizedBox(height: topPaddingHeight); + }, + ), + ); + } +} diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/tab_header.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart similarity index 76% rename from example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/tab_header.dart rename to example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart index 92aaa882..8cc207a0 100644 --- a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/tab_header.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart @@ -1,20 +1,22 @@ import 'package:flutter/material.dart'; -import '../../../../../../shared/components/delayed_frame_attached_dependent_builder.dart'; -import 'scrollable_provider.dart'; +import '../components/delayed_frame_attached_dependent_builder.dart'; +import '../components/scrollable_provider.dart'; class TabHeader extends StatelessWidget { const TabHeader({ super.key, - required this.bottomSheetOuterController, + required this.title, }); - final DraggableScrollableController bottomSheetOuterController; + final String title; @override Widget build(BuildContext context) { final screenTopPadding = MediaQueryData.fromView(View.of(context)).padding.top; + final outerScrollController = + BottomSheetScrollableProvider.outerScrollControllerOf(context); final innerScrollController = BottomSheetScrollableProvider.innerScrollControllerOf(context); @@ -22,15 +24,26 @@ class TabHeader extends StatelessWidget { pinned: true, delegate: PersistentHeader( child: DelayedControllerAttachmentBuilder( - listenable: bottomSheetOuterController, + listenable: outerScrollController, builder: (context, _) { - if (!bottomSheetOuterController.isAttached) { - return const SizedBox.shrink(); + if (!outerScrollController.isAttached || + innerScrollController.positions.length != 1) { + return Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: SizedBox( + width: double.infinity, + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ); } - final maxHeight = bottomSheetOuterController.sizeToPixels(1); + final maxHeight = outerScrollController.sizeToPixels(1); - final oldValue = bottomSheetOuterController.pixels; + final oldValue = outerScrollController.pixels; final oldMax = maxHeight; final oldMin = maxHeight - screenTopPadding; @@ -71,14 +84,12 @@ class TabHeader extends StatelessWidget { child: ClipRRect( child: IconButton( onPressed: () { - bottomSheetOuterController.animateTo( + outerScrollController.animateTo( 0.3, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, ); - BottomSheetScrollableProvider - .innerScrollControllerOf(context) - .animateTo( + innerScrollController.animateTo( 0, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, @@ -90,15 +101,9 @@ class TabHeader extends StatelessWidget { ), SizedBox(width: minimizeIndentSpacer), Text( - 'Stores & Config', + title, style: Theme.of(context).textTheme.titleLarge, ), - const Spacer(), - IconButton( - onPressed: () {}, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.help_outline), - ), ], ), ), 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 a7d69323..99580bc0 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 @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../contents/home/home_view_side.dart'; +import '../../contents/region_selection/region_selection_view_side.dart'; class SecondaryViewSide extends StatelessWidget { const SecondaryViewSide({ @@ -38,9 +39,11 @@ class SecondaryViewSide extends StatelessWidget { child: child, ), ), - child: selectedTab == 0 - ? HomeViewSide(constraints: constraints) - : const Placeholder(), + child: switch (selectedTab) { + 0 => HomeViewSide(constraints: constraints), + 1 => const RegionSelectionViewSide(), + _ => Placeholder(key: ValueKey(selectedTab)), + }, ), ), ); diff --git a/example/lib/src/shared/components/build_attribution.dart b/example/lib/src/shared/components/build_attribution.dart deleted file mode 100644 index 19e7c56b..00000000 --- a/example/lib/src/shared/components/build_attribution.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; - -class StandardAttribution extends StatelessWidget { - const StandardAttribution({ - super.key, - required this.urlTemplate, - }); - - final String urlTemplate; - - @override - Widget build(BuildContext context) => RichAttributionWidget( - alignment: AttributionAlignment.bottomLeft, - popupInitialDisplayDuration: const Duration(seconds: 3), - popupBorderRadius: BorderRadius.circular(16), - attributions: [ - TextSourceAttribution(Uri.parse(urlTemplate).host), - const TextSourceAttribution( - 'For demonstration purposes only', - prependCopyright: false, - textStyle: TextStyle(fontWeight: FontWeight.bold), - ), - const TextSourceAttribution( - 'Offline mapping made with FMTC', - prependCopyright: false, - textStyle: TextStyle(fontStyle: FontStyle.italic), - ), - LogoSourceAttribution( - Image.asset('assets/icons/ProjectIcon.png'), - tooltip: 'flutter_map_tile_caching', - ), - ], - ); -} diff --git a/example/lib/src/screens/main/map_view/state/region_selection_provider.dart b/example/lib/src/shared/state/region_selection_provider.dart similarity index 100% rename from example/lib/src/screens/main/map_view/state/region_selection_provider.dart rename to example/lib/src/shared/state/region_selection_provider.dart From 702dfb53a489b78ae840475f3e3478a868db2f37 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 22 Sep 2024 00:40:59 +0100 Subject: [PATCH 55/97] Improved example app Deprecated `BaseRegion` & `DownloadableRegion` `.when` & `.maybeWhen` Improved documentation --- example/lib/main.dart | 2 +- .../configure_download.dart | 4 +- .../custom_polygon_snapping_indicator.dart | 5 +- .../region_selection/region_shape.dart | 215 ++++++++------ .../additional_panes/additional_pane.dart | 38 --- .../additional_panes/line_region_pane.dart | 89 ------ .../additional_panes/slider_panel_base.dart | 55 ---- .../region_selection/side_panel/parent.dart | 72 ----- .../side_panel/primary_pane.dart | 182 ------------ .../side_panel/region_shape_button.dart | 28 -- .../src/screens/main/map_view/map_view.dart | 162 ++++------- .../stores_list/components/no_stores.dart | 4 +- .../contents/home/home_view_side.dart | 3 +- .../components/shape_selector.dart | 15 - .../animated_visibility_icon_button.dart | 64 +++++ .../shape_selector/shape_selector.dart | 266 ++++++++++++++++++ .../components/no_sub_regions.dart | 49 ++++ .../sub_regions_list/sub_regions_list.dart | 79 ++++++ .../e}/adjust_zoom_lvls_pane.dart | 4 +- .../e}/custom_slider_track_shape.dart | 6 +- .../region_selection_view_bottom_sheet.dart | 12 +- .../region_selection_view_side.dart | 97 ++++++- .../src/shared/state/general_provider.dart | 12 + .../state/region_selection_provider.dart | 94 +++++-- example/pubspec.yaml | 5 +- lib/src/regions/base_region.dart | 8 + lib/src/regions/downloadable_region.dart | 8 + lib/src/regions/shapes/multi.dart | 5 +- 28 files changed, 860 insertions(+), 723 deletions(-) delete mode 100644 example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart delete mode 100644 example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart delete mode 100644 example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart delete mode 100644 example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart delete mode 100644 example/lib/src/screens/main/map_view/components/region_selection/side_panel/primary_pane.dart delete mode 100644 example/lib/src/screens/main/map_view/components/region_selection/side_panel/region_shape_button.dart delete mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/components/no_sub_regions.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/sub_regions_list.dart rename example/lib/src/screens/main/{map_view/components/region_selection/side_panel/additional_panes => secondary_view/contents/region_selection/e}/adjust_zoom_lvls_pane.dart (98%) rename example/lib/src/screens/main/{map_view/components/region_selection/side_panel => secondary_view/contents/region_selection/e}/custom_slider_track_shape.dart (83%) diff --git a/example/lib/main.dart b/example/lib/main.dart index 28001296..5c024134 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,11 +10,11 @@ import 'src/screens/download/download.dart'; import 'src/screens/import/import.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/main/main.dart'; -import 'src/shared/state/region_selection_provider.dart'; import 'src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; import 'src/shared/state/general_provider.dart'; +import 'src/shared/state/region_selection_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/example/lib/src/screens/configure_download/configure_download.dart b/example/lib/src/screens/configure_download/configure_download.dart index 44892d76..6ca36880 100644 --- a/example/lib/src/screens/configure_download/configure_download.dart +++ b/example/lib/src/screens/configure_download/configure_download.dart @@ -30,7 +30,7 @@ class _ConfigureDownloadPopupState extends State { super.didChangeDependencies(); final provider = context.read(); - const FMTCStore('') + /*const FMTCStore('') .download .check( region ??= provider.region!.toDownloadable( @@ -41,7 +41,7 @@ class _ConfigureDownloadPopupState extends State { options: TileLayer(), ), ) - .then((v) => setState(() => maxTiles = v)); + .then((v) => setState(() => maxTiles = v));*/ } @override diff --git a/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart b/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart index 75f2f8e2..52c58cc4 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart @@ -12,8 +12,9 @@ class CustomPolygonSnappingIndicator extends StatelessWidget { @override Widget build(BuildContext context) { - final coords = context - .select>((p) => p.coordinates); + final coords = context.select>( + (p) => p.currentConstructingCoordinates, + ); return MarkerLayer( markers: [ diff --git a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart index 47a52f3d..c9fd1bc5 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart @@ -7,99 +7,136 @@ import 'package:provider/provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; class RegionShape extends StatelessWidget { - const RegionShape({ - super.key, - }); + const RegionShape({super.key}); + + static const _fullWorldPolygon = [ + LatLng(-90, 180), + LatLng(90, 180), + LatLng(90, -180), + LatLng(-90, -180), + ]; @override Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) { - if (provider.regionType == RegionType.line) { - if (provider.coordinates.isEmpty) return const SizedBox.shrink(); - return PolylineLayer( - polylines: [ - Polyline( - points: [ - ...provider.coordinates, - provider.currentNewPointPos, + builder: (context, provider, _) => Stack( + fit: StackFit.expand, + children: [ + for (final MapEntry(key: region, value: color) + in provider.constructedRegions.entries) + switch (region) { + RectangleRegion(:final bounds) => PolygonLayer( + polygons: [ + Polygon( + points: [ + bounds.northWest, + bounds.northEast, + bounds.southEast, + bounds.southWest, + ], + color: color.toColor(), + ), + ], + ), + CircleRegion(:final center, :final radius) => CircleLayer( + circles: [ + CircleMarker( + point: center, + radius: radius * 1000, + useRadiusInMeter: true, + color: color.toColor(), + ), + ], + ), + LineRegion(:final line, :final radius) => PolylineLayer( + polylines: [ + Polyline( + points: line, + strokeWidth: radius * 2, + useStrokeWidthInMeter: true, + color: color.toColor(), + ), + ], + ), + CustomPolygonRegion(:final outline) => PolygonLayer( + polygons: [ + Polygon( + points: outline, + color: color.toColor(), + ), + ], + ), + MultiRegion() => throw UnsupportedError( + 'Cannot support `MultiRegion`s here', + ), + }, + if (provider.currentConstructingCoordinates.isNotEmpty) + if (provider.currentRegionType == RegionType.line) + PolylineLayer( + polylines: [ + Polyline( + points: [ + ...provider.currentConstructingCoordinates, + provider.currentNewPointPos, + ], + color: Colors.white.withOpacity(2 / 3), + strokeWidth: provider.lineRadius * 2, + useStrokeWidthInMeter: true, + ), ], - borderColor: Colors.black, - borderStrokeWidth: 2, - color: Colors.green.withOpacity(2 / 3), - strokeWidth: provider.lineRadius * 2, - useStrokeWidthInMeter: true, - ), - ], - ); - } - - final List holePoints; - if (provider.coordinates.isEmpty) { - holePoints = []; - } else { - switch (provider.regionType) { - case RegionType.square: - final bounds = LatLngBounds.fromPoints( - provider.coordinates.length == 1 - ? [provider.coordinates[0], provider.currentNewPointPos] - : provider.coordinates, - ); - holePoints = [ - bounds.northWest, - bounds.northEast, - bounds.southEast, - bounds.southWest, - ]; - case RegionType.circle: - holePoints = CircleRegion( - provider.coordinates[0], - const Distance(roundResult: false).distance( - provider.coordinates[0], - provider.coordinates.length == 1 - ? provider.currentNewPointPos - : provider.coordinates[1], - ) / - 1000, - ).toOutline().toList(); - case RegionType.customPolygon: - holePoints = provider.isCustomPolygonComplete - ? provider.coordinates - : [ - ...provider.coordinates, - if (provider.customPolygonSnap) - provider.coordinates.first - else - provider.currentNewPointPos, - ]; - case RegionType.line: - throw UnsupportedError('Unreachable.'); - } - } - - return PolygonLayer( - key: UniqueKey(), - polygons: [ - Polygon( - points: [ - const LatLng(-90, 180), - const LatLng(90, 180), - const LatLng(90, -180), - const LatLng(-90, -180), - ], - holePointsList: holePoints.length < 3 - ? null - : [ - if (Polygon.isClockwise(holePoints)) - holePoints - else - holePoints.reversed.toList(), + ) + else + PolygonLayer( + polygons: [ + Polygon( + points: _fullWorldPolygon, + holePointsList: [ + switch (provider.currentRegionType) { + RegionType.circle => CircleRegion( + provider.currentConstructingCoordinates[0], + const Distance(roundResult: false).distance( + provider.currentConstructingCoordinates[0], + provider.currentConstructingCoordinates + .length == + 1 + ? provider.currentNewPointPos + : provider + .currentConstructingCoordinates[1], + ) / + 1000, + ).toOutline().toList(), + RegionType.rectangle => RectangleRegion( + LatLngBounds.fromPoints( + provider.currentConstructingCoordinates + .length == + 1 + ? [ + provider + .currentConstructingCoordinates[0], + provider.currentNewPointPos, + ] + : provider.currentConstructingCoordinates, + ), + ).toOutline().toList(), + RegionType.customPolygon => [ + ...provider.currentConstructingCoordinates, + if (provider.customPolygonSnap) + provider.currentConstructingCoordinates.first + else + provider.currentNewPointPos, + ], + _ => throw UnsupportedError('Unreachable.'), + }, ], - borderColor: Colors.black, - borderStrokeWidth: 2, - color: Theme.of(context).colorScheme.surface.withOpacity(0.5), - ), - ], - ); - }, + borderColor: Colors.black, + borderStrokeWidth: 2, + color: Theme.of(context) + .colorScheme + .surface + .withOpacity(0.5), + ), + ], + ), + ], + ), ); } diff --git a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart deleted file mode 100644 index 87033919..00000000 --- a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/additional_pane.dart +++ /dev/null @@ -1,38 +0,0 @@ -part of '../parent.dart'; - -class _AdditionalPane extends StatelessWidget { - const _AdditionalPane({ - required this.constraints, - required this.layoutDirection, - }); - - final BoxConstraints constraints; - final Axis layoutDirection; - - @override - Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => Stack( - fit: StackFit.passthrough, - children: [ - _SliderPanelBase( - constraints: constraints, - layoutDirection: layoutDirection, - isVisible: provider.regionType == RegionType.line, - child: layoutDirection == Axis.vertical - ? IntrinsicWidth( - child: LineRegionPane(layoutDirection: layoutDirection), - ) - : IntrinsicHeight( - child: LineRegionPane(layoutDirection: layoutDirection), - ), - ), - _SliderPanelBase( - constraints: constraints, - layoutDirection: layoutDirection, - isVisible: provider.openAdjustZoomLevelsSlider, - child: AdjustZoomLvlsPane(layoutDirection: layoutDirection), - ), - ], - ), - ); -} diff --git a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart deleted file mode 100644 index 75288d1e..00000000 --- a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/line_region_pane.dart +++ /dev/null @@ -1,89 +0,0 @@ -part of '../parent.dart'; - -class LineRegionPane extends StatelessWidget { - const LineRegionPane({ - super.key, - required this.layoutDirection, - }); - - final Axis layoutDirection; - - @override - Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => Flex( - direction: layoutDirection, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () async { - final provider = context.read(); - - final pickerResult = Platform.isAndroid || Platform.isIOS - ? await FilePicker.platform.pickFiles( - allowMultiple: true, - ) - : await FilePicker.platform.pickFiles( - dialogTitle: 'Import GPX', - type: FileType.custom, - allowedExtensions: ['gpx'], - allowMultiple: true, - ); - - if (pickerResult == null) return; - - final gpxReader = GpxReader(); - for (final path in pickerResult.files.map((e) => e.path)) { - provider.addCoordinates( - gpxReader - .fromString(await File(path!).readAsString()) - .trks - .map( - (e) => e.trksegs.map( - (e) => e.trkpts.map((e) => LatLng(e.lat!, e.lon!)), - ), - ) - .expand((e) => e) - .expand((e) => e), - ); - } - }, - icon: const Icon(Icons.route), - tooltip: 'Import from GPX', - ), - if (layoutDirection == Axis.vertical) - const Divider(height: 8) - else - const VerticalDivider(width: 8), - const SizedBox.square(dimension: 4), - if (layoutDirection == Axis.vertical) ...[ - Text('${provider.lineRadius.round()}m'), - const Text('radius'), - ], - if (layoutDirection == Axis.horizontal) - Text('${provider.lineRadius.round()}m radius'), - Expanded( - child: Padding( - padding: layoutDirection == Axis.vertical - ? const EdgeInsets.only(bottom: 12, top: 28) - : const EdgeInsets.only(left: 28, right: 12), - child: RotatedBox( - quarterTurns: layoutDirection == Axis.vertical ? 3 : 0, - child: SliderTheme( - data: SliderThemeData( - trackShape: _CustomSliderTrackShape(), - ), - child: Slider( - value: provider.lineRadius, - onChanged: (v) => provider.lineRadius = v, - min: 100, - max: 4000, - ), - ), - ), - ), - ), - ], - ), - ); -} diff --git a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart deleted file mode 100644 index 3e8c1af8..00000000 --- a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/slider_panel_base.dart +++ /dev/null @@ -1,55 +0,0 @@ -part of '../parent.dart'; - -class _SliderPanelBase extends StatelessWidget { - const _SliderPanelBase({ - required this.constraints, - required this.layoutDirection, - required this.isVisible, - required this.child, - }); - - final BoxConstraints constraints; - final Axis layoutDirection; - final bool isVisible; - final Widget child; - - @override - Widget build(BuildContext context) => IgnorePointer( - ignoring: !isVisible, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - opacity: isVisible ? 1 : 0, - child: AnimatedSlide( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - offset: isVisible - ? Offset.zero - : Offset( - layoutDirection == Axis.vertical ? -0.5 : 0, - layoutDirection == Axis.vertical ? 0 : 0.5, - ), - child: Container( - width: layoutDirection == Axis.vertical - ? null - : constraints.maxWidth < 500 - ? constraints.maxWidth - : null, - height: layoutDirection == Axis.horizontal - ? null - : constraints.maxHeight < 500 - ? constraints.maxHeight - : null, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(1028), - ), - padding: layoutDirection == Axis.vertical - ? const EdgeInsets.symmetric(vertical: 22, horizontal: 10) - : const EdgeInsets.symmetric(vertical: 10, horizontal: 22), - child: child, - ), - ), - ), - ); -} diff --git a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart deleted file mode 100644 index b3ac952c..00000000 --- a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/parent.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:gpx/gpx.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../../shared/misc/exts/interleave.dart'; -import '../../../../../configure_download/configure_download.dart'; -import '../../../../../../shared/state/region_selection_provider.dart'; - -part 'additional_panes/additional_pane.dart'; -part 'additional_panes/adjust_zoom_lvls_pane.dart'; -part 'additional_panes/line_region_pane.dart'; -part 'additional_panes/slider_panel_base.dart'; -part 'custom_slider_track_shape.dart'; -part 'primary_pane.dart'; -part 'region_shape_button.dart'; - -class RegionSelectionSidePanel extends StatelessWidget { - const RegionSelectionSidePanel({ - super.key, - required this.bottomPaddingWrapperBuilder, - required Axis layoutDirection, - }) : layoutDirection = - layoutDirection == Axis.vertical ? Axis.horizontal : Axis.vertical; - - final Widget Function(BuildContext context, Widget child)? - bottomPaddingWrapperBuilder; - final Axis layoutDirection; - - void finalizeSelection(BuildContext context) => - Navigator.of(context).pushNamed(ConfigureDownloadPopup.route); - - @override - Widget build(BuildContext context) { - final Widget child; - - if (layoutDirection == Axis.vertical) { - child = LayoutBuilder( - builder: (context, constraints) => IntrinsicHeight( - child: _PrimaryPane( - constraints: constraints, - layoutDirection: layoutDirection, - finalizeSelection: finalizeSelection, - ), - ), - ); - } else { - final subChild = LayoutBuilder( - builder: (context, constraints) => IntrinsicWidth( - child: _PrimaryPane( - constraints: constraints, - layoutDirection: layoutDirection, - finalizeSelection: finalizeSelection, - ), - ), - ); - - if (bottomPaddingWrapperBuilder != null) { - child = Builder( - builder: (context) => bottomPaddingWrapperBuilder!(context, subChild), - ); - } else { - child = subChild; - } - } - - return Center(child: FittedBox(child: child)); - } -} diff --git a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/primary_pane.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/primary_pane.dart deleted file mode 100644 index 8cd7d0c2..00000000 --- a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/primary_pane.dart +++ /dev/null @@ -1,182 +0,0 @@ -part of 'parent.dart'; - -class _PrimaryPane extends StatelessWidget { - const _PrimaryPane({ - required this.constraints, - required this.layoutDirection, - required this.finalizeSelection, - }); - - final BoxConstraints constraints; - final Axis layoutDirection; - final void Function(BuildContext context) finalizeSelection; - - static const regionShapes = { - RegionType.square: ( - selectedIcon: Icons.square, - unselectedIcon: Icons.square_outlined, - label: 'Rectangle', - ), - RegionType.circle: ( - selectedIcon: Icons.circle, - unselectedIcon: Icons.circle_outlined, - label: 'Circle', - ), - RegionType.line: ( - selectedIcon: Icons.polyline, - unselectedIcon: Icons.polyline_outlined, - label: 'Polyline + Radius', - ), - RegionType.customPolygon: ( - selectedIcon: Icons.pentagon, - unselectedIcon: Icons.pentagon_outlined, - label: 'Polygon', - ), - }; - - @override - Widget build(BuildContext context) => Flex( - direction: - layoutDirection == Axis.vertical ? Axis.horizontal : Axis.vertical, - crossAxisAlignment: CrossAxisAlignment.stretch, - verticalDirection: layoutDirection == Axis.horizontal - ? VerticalDirection.up - : VerticalDirection.down, - children: [ - Flex( - direction: layoutDirection, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(1028), - ), - padding: const EdgeInsets.all(12), - child: (layoutDirection == Axis.vertical - ? constraints.maxHeight - : constraints.maxWidth) < - 500 - ? Consumer( - builder: (context, provider, _) => IconButton( - icon: Icon( - regionShapes[provider.regionType]!.selectedIcon, - ), - onPressed: () => provider - ..regionType = regionShapes.keys.elementAt( - (regionShapes.keys - .toList() - .indexOf(provider.regionType) + - 1) % - 4, - ) - ..clearCoordinates(), - tooltip: 'Switch Region Shape', - ), - ) - : Flex( - direction: layoutDirection, - mainAxisSize: MainAxisSize.min, - children: regionShapes.entries - .map( - (e) => _RegionShapeButton( - type: e.key, - selectedIcon: Icon(e.value.selectedIcon), - unselectedIcon: Icon(e.value.unselectedIcon), - tooltip: e.value.label, - ), - ) - .interleave(const SizedBox.square(dimension: 12)) - .toList(), - ), - ), - const SizedBox.square(dimension: 12), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(1028), - ), - padding: const EdgeInsets.all(12), - child: Flex( - direction: layoutDirection, - mainAxisSize: MainAxisSize.min, - children: [ - Selector( - selector: (context, provider) => - provider.regionSelectionMethod, - builder: (context, method, _) => IconButton( - icon: Icon( - method == RegionSelectionMethod.useMapCenter - ? Icons.filter_center_focus - : Icons.ads_click, - ), - onPressed: () => context - .read() - .regionSelectionMethod = - method == RegionSelectionMethod.useMapCenter - ? RegionSelectionMethod.usePointer - : RegionSelectionMethod.useMapCenter, - tooltip: 'Switch Selection Method', - ), - ), - const SizedBox.square(dimension: 12), - IconButton( - icon: const Icon(Icons.delete_forever), - onPressed: () => context - .read() - .clearCoordinates(), - tooltip: 'Remove All Points', - ), - ], - ), - ), - const SizedBox.square(dimension: 12), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(1028), - ), - padding: const EdgeInsets.all(12), - child: Consumer( - builder: (context, provider, _) => Flex( - direction: layoutDirection, - mainAxisSize: MainAxisSize.min, - children: [ - if (provider.openAdjustZoomLevelsSlider) - IconButton.outlined( - icon: Icon( - layoutDirection == Axis.vertical - ? Icons.arrow_left - : Icons.arrow_drop_down, - ), - onPressed: () => - provider.openAdjustZoomLevelsSlider = false, - ) - else - IconButton( - icon: const Icon(Icons.zoom_in), - onPressed: () => - provider.openAdjustZoomLevelsSlider = true, - ), - const SizedBox.square(dimension: 12), - IconButton.filled( - icon: const Icon(Icons.done), - onPressed: provider.region != null - ? () => finalizeSelection(context) - : null, - ), - ], - ), - ), - ), - ], - ), - const SizedBox.square(dimension: 12), - _AdditionalPane( - constraints: constraints, - layoutDirection: layoutDirection, - ), - ], - ); -} diff --git a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/region_shape_button.dart b/example/lib/src/screens/main/map_view/components/region_selection/side_panel/region_shape_button.dart deleted file mode 100644 index 7c9763f5..00000000 --- a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/region_shape_button.dart +++ /dev/null @@ -1,28 +0,0 @@ -part of 'parent.dart'; - -class _RegionShapeButton extends StatelessWidget { - const _RegionShapeButton({ - required this.type, - required this.selectedIcon, - required this.unselectedIcon, - required this.tooltip, - }); - - final RegionType type; - final Icon selectedIcon; - final Icon unselectedIcon; - final String tooltip; - - @override - Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => IconButton( - icon: unselectedIcon, - selectedIcon: selectedIcon, - onPressed: () => provider - ..regionType = type - ..clearCoordinates(), - isSelected: provider.regionType == type, - tooltip: tooltip, - ), - ); -} 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 2771853e..a5d29e86 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -13,12 +13,11 @@ import 'package:provider/provider.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/region_selection/crosshairs.dart'; import 'components/region_selection/custom_polygon_snapping_indicator.dart'; import 'components/region_selection/region_shape.dart'; -import 'components/region_selection/side_panel/parent.dart'; -import '../../../shared/state/region_selection_provider.dart'; enum MapViewMode { standard, @@ -86,47 +85,43 @@ class _MapViewState extends State with TickerProviderStateMixin { final provider = context.read(); - if (provider.isCustomPolygonComplete) return; - - final List coords; - if (provider.customPolygonSnap && - provider.regionType == RegionType.customPolygon) { - coords = provider.addCoordinate(provider.coordinates.first); - provider.customPolygonSnap = false; - } else { - coords = provider.addCoordinate(provider.currentNewPointPos); - } + final newPoint = provider.currentNewPointPos; - if (coords.length < 2) return; + switch (provider.currentRegionType) { + case RegionType.rectangle: + final coords = provider.addCoordinate(newPoint); - switch (provider.regionType) { - case RegionType.square: if (coords.length == 2) { - provider.region = - RectangleRegion(LatLngBounds.fromPoints(coords)); - break; + final region = RectangleRegion(LatLngBounds.fromPoints(coords)); + provider.addConstructedRegion(region); } - provider - ..clearCoordinates() - ..addCoordinate(provider.currentNewPointPos); case RegionType.circle: + final coords = provider.addCoordinate(newPoint); + if (coords.length == 2) { - provider.region = CircleRegion( + final region = CircleRegion( coords[0], const Distance(roundResult: false) .distance(coords[0], coords[1]) / 1000, ); - break; + provider.addConstructedRegion(region); } - provider - ..clearCoordinates() - ..addCoordinate(provider.currentNewPointPos); case RegionType.line: - provider.region = LineRegion(coords, provider.lineRadius); + provider.addCoordinate(newPoint); case RegionType.customPolygon: - if (!provider.isCustomPolygonComplete) break; - provider.region = CustomPolygonRegion(coords); + if (provider.customPolygonSnap) { + // Force closed polygon + final coords = provider + .addCoordinate(provider.currentConstructingCoordinates.first); + + final region = CustomPolygonRegion(List.from(coords)); + provider + ..addConstructedRegion(region) + ..customPolygonSnap = false; + } else { + provider.addCoordinate(newPoint); + } } }, onSecondaryTap: (_, __) { @@ -146,8 +141,8 @@ class _MapViewState extends State with TickerProviderStateMixin { RegionSelectionMethod.usePointer) { provider.currentNewPointPos = point; - if (provider.regionType == RegionType.customPolygon) { - final coords = provider.coordinates; + if (provider.currentRegionType == RegionType.customPolygon) { + final coords = provider.currentConstructingCoordinates; if (coords.length > 1) { final newPointPos = _mapController.mapController.camera .latLngToScreenPoint(coords.first) @@ -171,8 +166,8 @@ class _MapViewState extends State with TickerProviderStateMixin { RegionSelectionMethod.useMapCenter) { provider.currentNewPointPos = position.center; - if (provider.regionType == RegionType.customPolygon) { - final coords = provider.coordinates; + if (provider.currentRegionType == RegionType.customPolygon) { + final coords = provider.currentConstructingCoordinates; if (coords.length > 1) { final newPointPos = _mapController.mapController.camera .latLngToScreenPoint(coords.first) @@ -210,6 +205,9 @@ class _MapViewState extends State with TickerProviderStateMixin { ); } }, + onMapReady: () { + context.read().animatedMapController = _mapController; + }, ); return StreamBuilder( @@ -332,76 +330,36 @@ class _MapViewState extends State with TickerProviderStateMixin { ], ); - return LayoutBuilder( - builder: (context, constraints) { - final double sidePanelLeft = - switch ((widget.layoutDirection, widget.mode)) { - (Axis.vertical, _) => 0, - (Axis.horizontal, MapViewMode.regionSelect) => 0, - (Axis.horizontal, MapViewMode.standard) => -85, - }; - final double sidePanelBottom = - switch ((widget.layoutDirection, widget.mode)) { - (Axis.horizontal, _) => 0, - (Axis.vertical, MapViewMode.regionSelect) => 0, - (Axis.vertical, MapViewMode.standard) => -85, - }; - - return Stack( - fit: StackFit.expand, - children: [ - MouseRegion( - opaque: false, - cursor: widget.mode == MapViewMode.standard || - context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter - ? MouseCursor.defer - : context.select( - (p) => p.customPolygonSnap, - ) - ? SystemMouseCursors.none - : SystemMouseCursors.precise, - child: map, - ), - // TODO: Use AnimatedSwitcher for performance - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - left: sidePanelLeft, - bottom: sidePanelBottom, - child: SizedBox( - height: widget.layoutDirection == Axis.horizontal - ? constraints.maxHeight - : null, - width: widget.layoutDirection == Axis.horizontal - ? null - : constraints.maxWidth, - child: Padding( - padding: const EdgeInsets.all(8), - child: RegionSelectionSidePanel( - layoutDirection: widget.layoutDirection, - bottomPaddingWrapperBuilder: - widget.bottomPaddingWrapperBuilder, - ), - ), - ), - ), - if (widget.mode == MapViewMode.regionSelect && - context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter && - !context.select( + return Stack( + fit: StackFit.expand, + children: [ + MouseRegion( + opaque: false, + cursor: widget.mode == MapViewMode.standard || + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter + ? MouseCursor.defer + : context.select( (p) => p.customPolygonSnap, - )) - const Center(child: Crosshairs()), - ], - ); - }, + ) + ? SystemMouseCursors.none + : SystemMouseCursors.precise, + child: map, + ), + if (widget.mode == MapViewMode.regionSelect && + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter && + !context.select( + (p) => p.customPolygonSnap, + )) + const Center(child: Crosshairs()), + ], ); }, ); diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart index 33aa8206..c6544fef 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart @@ -4,9 +4,7 @@ import '../../../../../../../import/import.dart'; import '../../../../../../../store_editor/store_editor.dart'; class NoStores extends StatelessWidget { - const NoStores({ - super.key, - }); + const NoStores({super.key}); @override Widget build(BuildContext context) => SliverFillRemaining( diff --git a/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart index e9353ee2..b2534cc1 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart @@ -34,12 +34,11 @@ class _HomeViewSideState extends State { child: CustomScrollView( slivers: [ SliverPadding( - padding: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.only(top: 16, bottom: 16), sliver: StoresList( useCompactLayout: widget.constraints.maxWidth / 3 < 500, ), ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), ), diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector.dart deleted file mode 100644 index e91a4b52..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; - -class ShapeSelector extends StatefulWidget { - const ShapeSelector({super.key}); - - @override - State createState() => _ShapeSelectorState(); -} - -class _ShapeSelectorState extends State { - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart new file mode 100644 index 00000000..5b8d46fc --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart @@ -0,0 +1,64 @@ +part of '../shape_selector.dart'; + +class _AnimatedVisibilityIconButton extends StatelessWidget { + const _AnimatedVisibilityIconButton.outlined({ + required this.icon, + this.onPressed, + this.tooltip, + required this.isVisible, + // ignore: avoid_field_initializers_in_const_classes + }) : _mode = 0; + + const _AnimatedVisibilityIconButton.filledTonal({ + required this.icon, + this.onPressed, + this.tooltip, + required this.isVisible, + // ignore: avoid_field_initializers_in_const_classes + }) : _mode = 1; + + const _AnimatedVisibilityIconButton.filled({ + required this.icon, + this.onPressed, + this.tooltip, + required this.isVisible, + // ignore: avoid_field_initializers_in_const_classes + }) : _mode = 2; + + final Icon icon; + final void Function()? onPressed; + final String? tooltip; + final bool isVisible; + + final int _mode; + + @override + Widget build(BuildContext context) => AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + height: isVisible ? 40 : 0, + width: isVisible ? 48 : 0, + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: switch (_mode) { + 0 => IconButton.outlined( + onPressed: onPressed, + icon: FittedBox(child: icon), + tooltip: tooltip, + ), + 1 => IconButton.filledTonal( + onPressed: onPressed, + icon: FittedBox(child: icon), + tooltip: tooltip, + ), + 2 => IconButton.filled( + onPressed: onPressed, + icon: FittedBox(child: icon), + tooltip: tooltip, + ), + _ => throw UnsupportedError('Unreachable.'), + }, + ), + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart new file mode 100644 index 00000000..2792e8be --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart @@ -0,0 +1,266 @@ +import 'dart:io'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:gpx/gpx.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../shared/state/region_selection_provider.dart'; + +part 'components/animated_visibility_icon_button.dart'; + +class ShapeSelector extends StatefulWidget { + const ShapeSelector({super.key}); + + @override + State createState() => _ShapeSelectorState(); +} + +class _ShapeSelectorState extends State { + static const _regionShapes = { + RegionType.rectangle: ( + selectedIcon: Icons.square, + unselectedIcon: Icons.square_outlined, + label: 'Rectangle', + ), + RegionType.circle: ( + selectedIcon: Icons.circle, + unselectedIcon: Icons.circle_outlined, + label: 'Circle', + ), + RegionType.line: ( + selectedIcon: Icons.polyline, + unselectedIcon: Icons.polyline_outlined, + label: 'Polyline + Radius', + ), + RegionType.customPolygon: ( + selectedIcon: Icons.pentagon, + unselectedIcon: Icons.pentagon_outlined, + label: 'Polygon', + ), + }; + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: _regionShapes.entries + .map( + (e) => ButtonSegment( + value: e.key, + icon: Icon( + provider.currentRegionType == e.key + ? e.value.selectedIcon + : e.value.unselectedIcon, + ), + tooltip: e.value.label, + ), + ) + .toList(), + selected: {provider.currentRegionType}, + showSelectedIcon: false, + onSelectionChanged: (type) => provider + ..currentRegionType = type.single + ..clearCoordinates(), + style: + const ButtonStyle(visualDensity: VisualDensity.comfortable), + ), + ), + AnimatedCrossFade( + duration: const Duration(milliseconds: 250), + firstCurve: Curves.easeInOut, + secondCurve: Curves.easeInOut, + sizeCurve: Curves.easeInOut, + firstChild: Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(99), + ), + child: Row( + children: [ + Expanded( + child: Slider( + value: provider.lineRadius, + onChanged: (v) => provider.lineRadius = v, + min: 100, + max: 5000, + ), + ), + Text( + '${provider.lineRadius.round().toString().padLeft(4, '0')}m', + ), + const VerticalDivider(), + IconButton.outlined( + onPressed: () async { + final provider = context.read(); + + final pickerResult = Platform.isAndroid || Platform.isIOS + ? await FilePicker.platform.pickFiles( + allowMultiple: true, + ) + : await FilePicker.platform.pickFiles( + dialogTitle: 'Import GPX', + type: FileType.custom, + allowedExtensions: ['gpx'], + allowMultiple: true, + ); + + if (pickerResult == null) return; + + final gpxReader = GpxReader(); + for (final path + in pickerResult.files.map((e) => e.path)) { + provider.addCoordinates( + gpxReader + .fromString(await File(path!).readAsString()) + .trks + .map( + (e) => e.trksegs.map( + (e) => e.trkpts + .map((e) => LatLng(e.lat!, e.lon!)), + ), + ) + .expand((e) => e) + .expand((e) => e), + ); + } + }, + icon: const Icon(Icons.file_open_rounded), + tooltip: 'Import from GPX', + ), + ], + ), + ), + secondChild: const SizedBox( + width: double.infinity, + ), + crossFadeState: provider.currentRegionType == RegionType.line + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const AutoSizeText( + 'Tap to add point', + maxLines: 1, + minFontSize: 0, + ), + AutoSizeText( + provider.regionSelectionMethod == + RegionSelectionMethod.useMapCenter + ? 'at map center' + : 'at tap position', + maxLines: 1, + minFontSize: 0, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + FittedBox( + child: Row( + children: [ + const SizedBox.shrink(), + IconButton.outlined( + onPressed: () => provider.regionSelectionMethod = + provider.regionSelectionMethod == + RegionSelectionMethod.useMapCenter + ? RegionSelectionMethod.usePointer + : RegionSelectionMethod.useMapCenter, + icon: Icon( + provider.regionSelectionMethod == + RegionSelectionMethod.useMapCenter + ? Icons.filter_center_focus + : Icons.ads_click, + ), + ), + _AnimatedVisibilityIconButton.outlined( + onPressed: + provider.currentConstructingCoordinates.length < 2 + ? null + : _removeLastCoordinate, + icon: const Icon(Icons.backspace), + tooltip: 'Remove last coordinate (alt. interact)', + isVisible: + provider.currentRegionType == RegionType.line || + provider.currentRegionType == + RegionType.customPolygon, + ), + const SizedBox(width: 8), + IconButton.outlined( + onPressed: provider.currentConstructingCoordinates.isEmpty + ? null + : _clearCoordinates, + icon: const Icon(Icons.delete), + ), + _AnimatedVisibilityIconButton.filledTonal( + onPressed: + provider.currentConstructingCoordinates.length < 2 + ? null + : _addSubRegion, + icon: const Icon(Icons.add), + tooltip: 'Add sub-region', + isVisible: provider.currentRegionType == RegionType.line, + ), + _AnimatedVisibilityIconButton.filled( + onPressed: + provider.currentConstructingCoordinates.length < 2 + ? null + : _completeRegion, + icon: const Icon(Icons.done), + tooltip: 'Complete region', + isVisible: + provider.currentRegionType == RegionType.line && + provider.constructedRegions.length <= 1, + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + void _completeRegion() { + _addSubRegion(); + context.read().isDownloadSetupPanelVisible = true; + } + + void _addSubRegion() { + final provider = context.read(); + provider.addConstructedRegion( + LineRegion(provider.currentConstructingCoordinates, provider.lineRadius), + ); + } + + void _removeLastCoordinate() { + context.read().removeLastCoordinate(); + } + + void _clearCoordinates() { + context.read().clearCoordinates(); + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/components/no_sub_regions.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/components/no_sub_regions.dart new file mode 100644 index 00000000..702b6906 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/components/no_sub_regions.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class NoSubRegions extends StatelessWidget { + const NoSubRegions({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.download, size: 64), + const SizedBox(height: 12), + Text( + 'Bulk downloading', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + const Text( + 'To bulk download a map, first create a region. Select the ' + 'shape above, and tap on the map to add points. Once a ' + 'region has been finished, download it immediately, or add ' + 'it to the list of sub-regions to download.', + textAlign: TextAlign.center, + ), + const Divider(height: 82), + const Icon(Icons.view_cozy_outlined, size: 64), + const SizedBox(height: 12), + Text( + 'No sub-regions selected', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + const Text( + 'FMTC supports `MultiRegion`s formed of multiple other ' + 'regions.\nYou can select an area to download and use the ' + 'panel below to download it, or add it to the list of ' + 'sub-regions using the button above.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/sub_regions_list.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/sub_regions_list.dart new file mode 100644 index 00000000..0055357b --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/sub_regions_list.dart @@ -0,0 +1,79 @@ +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:provider/provider.dart'; + +import '../../../../../../../shared/state/general_provider.dart'; +import '../../../../../../../shared/state/region_selection_provider.dart'; + +class SubRegionsList extends StatefulWidget { + const SubRegionsList({super.key}); + + @override + State createState() => _SubRegionsListState(); +} + +class _SubRegionsListState extends State { + @override + Widget build(BuildContext context) { + final constructedRegions = + context.select>( + (p) => p.constructedRegions, + ); + + return SliverList.builder( + itemCount: constructedRegions.length, + itemBuilder: (context, index) { + final region = constructedRegions.keys.elementAt(index); + final color = constructedRegions.values.elementAt(index).toColor(); + + return ListTile( + leading: switch (region) { + RectangleRegion() => Icon(Icons.rectangle, color: color), + CircleRegion() => Icon(Icons.circle, color: color), + LineRegion() => Icon(Icons.polyline, color: color), + CustomPolygonRegion() => Icon(Icons.pentagon, color: color), + _ => throw UnsupportedError('Cannot support `MultiRegion`s here'), + }, + title: switch (region) { + RectangleRegion() => const Text('Rectangle Region'), + CircleRegion() => const Text('Circle Region'), + LineRegion() => const Text('Line Region'), + CustomPolygonRegion() => const Text('Custom Polygon Region'), + _ => throw UnsupportedError('Cannot support `MultiRegion`s here'), + }, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + context + .read() + .animatedMapController + .animatedFitCamera( + cameraFit: CameraFit.bounds( + bounds: LatLngBounds.fromPoints( + region.toOutline().toList(), + ), + padding: const EdgeInsets.all(16), + ), + ); + }, + icon: const Icon(Icons.filter_center_focus), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () { + context + .read() + .removeConstructedRegion(region); + }, + icon: const Icon(Icons.delete), + ), + ], + ), + ); + }, + ); + } +} diff --git a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/e/adjust_zoom_lvls_pane.dart similarity index 98% rename from example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart rename to example/lib/src/screens/main/secondary_view/contents/region_selection/e/adjust_zoom_lvls_pane.dart index c7458ea7..fc69fec0 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/additional_panes/adjust_zoom_lvls_pane.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/e/adjust_zoom_lvls_pane.dart @@ -1,4 +1,5 @@ -part of '../parent.dart'; + +/* class AdjustZoomLvlsPane extends StatelessWidget { const AdjustZoomLvlsPane({ @@ -54,3 +55,4 @@ class AdjustZoomLvlsPane extends StatelessWidget { ), ); } +*/ diff --git a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/e/custom_slider_track_shape.dart similarity index 83% rename from example/lib/src/screens/main/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart rename to example/lib/src/screens/main/secondary_view/contents/region_selection/e/custom_slider_track_shape.dart index e8f54d21..7b65063e 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/side_panel/custom_slider_track_shape.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/e/custom_slider_track_shape.dart @@ -1,7 +1,7 @@ -part of 'parent.dart'; - // From https://stackoverflow.com/a/65662764/11846040 -class _CustomSliderTrackShape extends RoundedRectSliderTrackShape { +import 'package:flutter/material.dart'; + +class CustomSliderTrackShape extends RoundedRectSliderTrackShape { @override Rect getPreferredRect({ required RenderBox parentBox, diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart index 1a997565..a0e3badc 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart @@ -3,7 +3,8 @@ import 'package:flutter/material.dart'; import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; import '../../layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart'; import '../../layouts/bottom_sheet/utils/tab_header.dart'; -import 'components/shape_selector.dart'; +import 'components/shape_selector/shape_selector.dart'; +import 'components/sub_regions_list/sub_regions_list.dart'; class RegionSelectionViewBottomSheet extends StatelessWidget { const RegionSelectionViewBottomSheet({super.key}); @@ -24,6 +25,15 @@ class RegionSelectionViewBottomSheet extends StatelessWidget { ), SliverToBoxAdapter(child: Divider(height: 24)), SliverToBoxAdapter(child: SizedBox(height: 6)), + SubRegionsList(), + SliverToBoxAdapter(child: SizedBox(height: 6)), + SliverToBoxAdapter(child: Divider(height: 24)), + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: ShapeSelector(), + ), + ), SliverToBoxAdapter(child: SizedBox(height: 16)), ], ); diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart index 90fcf27d..4d217a0a 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart @@ -1,15 +1,100 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../../../shared/state/region_selection_provider.dart'; import '../../layouts/side/components/panel.dart'; -import 'components/shape_selector.dart'; +import 'components/shape_selector/shape_selector.dart'; +import 'components/sub_regions_list/components/no_sub_regions.dart'; +import 'components/sub_regions_list/sub_regions_list.dart'; class RegionSelectionViewSide extends StatelessWidget { const RegionSelectionViewSide({super.key}); @override - Widget build(BuildContext context) => const Column( - children: [ - SideViewPanel(child: ShapeSelector()), - ], - ); + Widget build(BuildContext context) { + final hasConstructedRegions = context.select( + (p) => p.constructedRegions.isNotEmpty, + ); + + return Column( + children: [ + const SideViewPanel(child: ShapeSelector()), + const SizedBox(height: 16), + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + color: Theme.of(context).colorScheme.surface, + ), + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + CustomScrollView( + slivers: [ + SliverPadding( + padding: hasConstructedRegions + ? const EdgeInsets.only(top: 16, bottom: 16 + 52) + : EdgeInsets.zero, + sliver: hasConstructedRegions + ? const SubRegionsList() + : const NoSubRegions(), + ), + ], + ), + PositionedDirectional( + end: 8, + bottom: 8, + child: IgnorePointer( + ignoring: !hasConstructedRegions, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + opacity: hasConstructedRegions ? 1 : 0, + 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: () {}, + label: const Text('Configure Download'), + icon: const Icon(Icons.tune), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + //const SizedBox(height: 16), + /*const SideViewPanel(child: ShapeSelector()), + const SizedBox(height: 16),*/ + ], + ); + } } diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart index 0903d777..cb356cfb 100644 --- a/example/lib/src/shared/state/general_provider.dart +++ b/example/lib/src/shared/state/general_provider.dart @@ -1,10 +1,22 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import '../misc/internal_store_read_write_behaviour.dart'; import '../misc/shared_preferences.dart'; class GeneralProvider extends ChangeNotifier { + AnimatedMapController? _animatedMapController; + AnimatedMapController get animatedMapController => + _animatedMapController ?? + (throw StateError( + '`(Animated)MapController` must be attached before usage', + )); + set animatedMapController(AnimatedMapController controller) { + _animatedMapController = controller; + notifyListeners(); + } + BrowseStoreStrategy? _inheritableBrowseStoreStrategy = () { final storedStrategyName = sharedPrefs .getString(SharedPrefsKeys.inheritableBrowseStoreStrategy.name); diff --git a/example/lib/src/shared/state/region_selection_provider.dart b/example/lib/src/shared/state/region_selection_provider.dart index 0a988e98..1b31e344 100644 --- a/example/lib/src/shared/state/region_selection_provider.dart +++ b/example/lib/src/shared/state/region_selection_provider.dart @@ -1,6 +1,7 @@ import 'dart:io'; +import 'dart:math'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:latlong2/latlong.dart'; @@ -10,20 +11,21 @@ enum RegionSelectionMethod { } enum RegionType { - square, + rectangle, circle, line, customPolygon, } class RegionSelectionProvider extends ChangeNotifier { - RegionSelectionMethod _regionSelectionMethod = + RegionSelectionMethod _currentRegionSelectionMethod = Platform.isAndroid || Platform.isIOS ? RegionSelectionMethod.useMapCenter : RegionSelectionMethod.usePointer; - RegionSelectionMethod get regionSelectionMethod => _regionSelectionMethod; + RegionSelectionMethod get regionSelectionMethod => + _currentRegionSelectionMethod; set regionSelectionMethod(RegionSelectionMethod newMethod) { - _regionSelectionMethod = newMethod; + _currentRegionSelectionMethod = newMethod; notifyListeners(); } @@ -34,45 +36,75 @@ class RegionSelectionProvider extends ChangeNotifier { notifyListeners(); } - RegionType _regionType = RegionType.square; - RegionType get regionType => _regionType; - set regionType(RegionType newType) { - _regionType = newType; + RegionType _currentRegionType = RegionType.rectangle; + RegionType get currentRegionType => _currentRegionType; + set currentRegionType(RegionType newType) { + _currentRegionType = newType; notifyListeners(); } - BaseRegion? _region; - BaseRegion? get region => _region; - set region(BaseRegion? newRegion) { - _region = newRegion; + final _constructedRegions = {}; + Map get constructedRegions => + Map.unmodifiable(_constructedRegions); + + void addConstructedRegion(BaseRegion region) { + assert(region is! MultiRegion, 'Cannot be a `MultiRegion`'); + + HSLColor generateUnusedRandomColor({int iteration = 0}) { + final color = HSLColor.fromAHSL(1, Random().nextDouble() * 360, 1, 0.5); + + if (iteration > 18) return color; + + for (final usedColor in _constructedRegions.values) { + final diff = (color.hue - usedColor.hue).abs(); + if (diff > 20) continue; + return generateUnusedRandomColor(iteration: iteration + 1); + } + + return color; + } + + _constructedRegions[region] = generateUnusedRandomColor(); + + _currentConstructingCoordinates.clear(); + + notifyListeners(); + } + + void removeConstructedRegion(BaseRegion region) { + _constructedRegions.remove(region); + notifyListeners(); + } + + void clearConstructedRegions() { + _constructedRegions.clear(); notifyListeners(); } - final List _coordinates = []; - List get coordinates => List.from(_coordinates); + final List _currentConstructingCoordinates = []; + List get currentConstructingCoordinates => + List.unmodifiable(_currentConstructingCoordinates); List addCoordinate(LatLng coord) { - _coordinates.add(coord); + _currentConstructingCoordinates.add(coord); notifyListeners(); - return _coordinates; + return _currentConstructingCoordinates; } List addCoordinates(Iterable coords) { - _coordinates.addAll(coords); + _currentConstructingCoordinates.addAll(coords); notifyListeners(); - return _coordinates; + return _currentConstructingCoordinates; } void clearCoordinates() { - _coordinates.clear(); - _region = null; + _currentConstructingCoordinates.clear(); notifyListeners(); } void removeLastCoordinate() { - if (_coordinates.isNotEmpty) _coordinates.removeLast(); - if (_regionType == RegionType.customPolygon - ? !isCustomPolygonComplete - : _coordinates.length < 2) _region = null; + if (_currentConstructingCoordinates.isNotEmpty) { + _currentConstructingCoordinates.removeLast(); + } notifyListeners(); } @@ -90,11 +122,6 @@ class RegionSelectionProvider extends ChangeNotifier { notifyListeners(); } - bool get isCustomPolygonComplete => - _regionType == RegionType.customPolygon && - _coordinates.length >= 2 && - _coordinates.first == _coordinates.last; - bool _openAdjustZoomLevelsSlider = false; bool get openAdjustZoomLevelsSlider => _openAdjustZoomLevelsSlider; set openAdjustZoomLevelsSlider(bool newState) { @@ -129,4 +156,11 @@ class RegionSelectionProvider extends ChangeNotifier { _endTile = endTile; notifyListeners(); } + + bool _isDownloadSetupPanelVisible = false; + bool get isDownloadSetupPanelVisible => _isDownloadSetupPanelVisible; + set isDownloadSetupPanelVisible(bool newState) { + _isDownloadSetupPanelVisible = newState; + notifyListeners(); + } } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 645cc991..85a96c5e 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,8 +11,8 @@ environment: dependencies: async: ^2.11.0 + auto_size_text: ^3.0.0 collection: ^1.18.0 - dart_earcut: ^1.1.0 file_picker: ^8.1.2 flutter: sdk: flutter @@ -32,6 +32,9 @@ dependencies: stream_transform: ^2.1.0 dependency_overrides: + flutter_map: + git: + url: https://github.com/fleaflet/flutter_map.git flutter_map_tile_caching: path: ../ diff --git a/lib/src/regions/base_region.dart b/lib/src/regions/base_region.dart index 78b7d443..236cedba 100644 --- a/lib/src/regions/base_region.dart +++ b/lib/src/regions/base_region.dart @@ -33,6 +33,10 @@ sealed class BaseRegion { /// /// Requires all region types to have a defined handler. See [maybeWhen] for /// the equivalent where this is not required. + @Deprecated( + 'Prefer using a pattern matching selection (such as `if case` or ' + '`switch`). This will be removed in a future version.', + ) T when({ required T Function(RectangleRegion rectangle) rectangle, required T Function(CircleRegion circle) circle, @@ -52,6 +56,10 @@ sealed class BaseRegion { /// /// If the specified method is not defined for the type of region which this /// region is, `null` will be returned. + @Deprecated( + 'Prefer using a pattern matching selection (such as `if case` or ' + '`switch`). This will be removed in a future version.', + ) T? maybeWhen({ T Function(RectangleRegion rectangle)? rectangle, T Function(CircleRegion circle)? circle, diff --git a/lib/src/regions/downloadable_region.dart b/lib/src/regions/downloadable_region.dart index 5a8d4bb3..3e00db0f 100644 --- a/lib/src/regions/downloadable_region.dart +++ b/lib/src/regions/downloadable_region.dart @@ -78,6 +78,10 @@ class DownloadableRegion { /// /// Requires all region types to have a defined handler. See [maybeWhen] for /// the equivalent where this is not required. + @Deprecated( + 'Prefer using a pattern matching selection (such as `if case` or ' + '`switch`). This will be removed in a future version.', + ) T when({ required T Function(DownloadableRegion rectangle) rectangle, @@ -99,6 +103,10 @@ class DownloadableRegion { /// /// If the specified method is not defined for the type of region which this /// region is, `null` will be returned. + @Deprecated( + 'Prefer using a pattern matching selection (such as `if case` or ' + '`switch`). This will be removed in a future version.', + ) T? maybeWhen({ T Function(DownloadableRegion rectangle)? rectangle, T Function(DownloadableRegion circle)? circle, diff --git a/lib/src/regions/shapes/multi.dart b/lib/src/regions/shapes/multi.dart index 133500de..c4f3de77 100644 --- a/lib/src/regions/shapes/multi.dart +++ b/lib/src/regions/shapes/multi.dart @@ -6,7 +6,10 @@ part of '../../../../flutter_map_tile_caching.dart'; /// A region formed from multiple other [BaseRegion]s /// /// When downloading, each sub-region specified in [regions] is downloaded -/// consecutively. [MultiRegion]s may be nested. +/// consecutively. Overlaps are not resolved into single regions, so it is +/// recommended to enable `skipExistingTiles` in [StoreDownload.startForeground]. +/// +/// [MultiRegion]s may be nested. /// /// [toOutline] is not supported and will always throw. class MultiRegion extends BaseRegion { From 23e640856ce7b69fee6e3cfe8888afdc73839918 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 30 Sep 2024 21:46:31 +0100 Subject: [PATCH 56/97] Improved example app --- example/lib/main.dart | 6 +- example/lib/src/screens/main/main.dart | 2 +- .../debugging_tile_builder.dart | 4 +- .../region_selection/region_shape.dart | 53 ++++++-- .../src/screens/main/map_view/map_view.dart | 29 +++-- ...nload_configuration_view_bottom_sheet.dart | 32 +++++ .../download_configuration_view_side.dart | 113 ++++++++++++++++++ .../browse_store_strategy_selector.dart | 2 +- .../components/store_tiles/store_tile.dart | 2 +- .../shape_selector/shape_selector.dart | 5 +- .../components/shared/to_config_method.dart | 29 +++++ .../e/adjust_zoom_lvls_pane.dart | 58 --------- .../e/custom_slider_track_shape.dart | 19 --- .../region_selection_view_bottom_sheet.dart | 8 -- .../region_selection_view_side.dart | 15 +-- .../layouts/bottom_sheet/bottom_sheet.dart | 11 +- .../components/scrollable_provider.dart | 9 +- .../secondary_view/layouts/side/side.dart | 9 +- .../components/numerical_input_row.dart | 2 +- .../components/options_pane.dart | 2 +- .../components/region_information.dart | 0 .../components/start_download_button.dart | 2 +- .../components/store_selector.dart | 0 .../configure_download.dart | 4 +- .../state/configure_download_provider.dart | 0 .../confirm_cancellation_dialog.dart | 0 .../download/components/main_statistics.dart | 0 .../multi_linear_progress_indicator.dart | 0 .../download/components/stat_display.dart | 0 .../download/components/stats_table.dart | 0 .../screens/{ => old}/download/download.dart | 2 +- .../internal_workers/standard/worker.dart | 2 +- 32 files changed, 278 insertions(+), 142 deletions(-) create mode 100644 example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_side.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/components/shared/to_config_method.dart delete mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/e/adjust_zoom_lvls_pane.dart delete mode 100644 example/lib/src/screens/main/secondary_view/contents/region_selection/e/custom_slider_track_shape.dart rename example/lib/src/screens/{ => old}/configure_download/components/numerical_input_row.dart (98%) rename example/lib/src/screens/{ => old}/configure_download/components/options_pane.dart (95%) rename example/lib/src/screens/{ => old}/configure_download/components/region_information.dart (100%) rename example/lib/src/screens/{ => old}/configure_download/components/start_download_button.dart (98%) rename example/lib/src/screens/{ => old}/configure_download/components/store_selector.dart (100%) rename example/lib/src/screens/{ => old}/configure_download/configure_download.dart (97%) rename example/lib/src/screens/{ => old}/configure_download/state/configure_download_provider.dart (100%) rename example/lib/src/screens/{ => old}/download/components/confirm_cancellation_dialog.dart (100%) rename example/lib/src/screens/{ => old}/download/components/main_statistics.dart (100%) rename example/lib/src/screens/{ => old}/download/components/multi_linear_progress_indicator.dart (100%) rename example/lib/src/screens/{ => old}/download/components/stat_display.dart (100%) rename example/lib/src/screens/{ => old}/download/components/stats_table.dart (100%) rename example/lib/src/screens/{ => old}/download/download.dart (99%) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5c024134..cfa54ae0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -4,13 +4,13 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'src/screens/configure_download/configure_download.dart'; -import 'src/screens/configure_download/state/configure_download_provider.dart'; -import 'src/screens/download/download.dart'; import 'src/screens/import/import.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/main/main.dart'; import 'src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart'; +import 'src/screens/old/configure_download/configure_download.dart'; +import 'src/screens/old/configure_download/state/configure_download_provider.dart'; +import 'src/screens/old/download/download.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; import 'src/shared/state/general_provider.dart'; diff --git a/example/lib/src/screens/main/main.dart b/example/lib/src/screens/main/main.dart index 10575722..31fdc51a 100644 --- a/example/lib/src/screens/main/main.dart +++ b/example/lib/src/screens/main/main.dart @@ -23,7 +23,7 @@ class _MainScreenState extends State { Widget build(BuildContext context) { final mapMode = switch (selectedTab) { 0 => MapViewMode.standard, - 1 => MapViewMode.regionSelect, + 1 => MapViewMode.downloadRegion, _ => throw UnimplementedError(), }; diff --git a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart index d9b4e594..05175ad6 100644 --- a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart +++ b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart @@ -27,10 +27,10 @@ class DebuggingTileBuilder extends StatelessWidget { DecoratedBox( decoration: BoxDecoration( border: Border.all( - color: Colors.black.withOpacity(0.8), + color: Colors.black.withValues(alpha: 0.8), width: 2, ), - color: Colors.white.withOpacity(0.5), + color: Colors.white.withValues(alpha: 0.5), ), position: DecorationPosition.foreground, child: tileWidget, diff --git a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart index c9fd1bc5..88ecb297 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart @@ -33,7 +33,7 @@ class RegionShape extends StatelessWidget { bounds.southEast, bounds.southWest, ], - color: color.toColor(), + color: color.toColor().withValues(alpha: 0.7), ), ], ), @@ -43,25 +43,35 @@ class RegionShape extends StatelessWidget { point: center, radius: radius * 1000, useRadiusInMeter: true, - color: color.toColor(), + color: color.toColor().withValues(alpha: 0.7), ), ], ), - LineRegion(:final line, :final radius) => PolylineLayer( - polylines: [ - Polyline( + LineRegion() => PolygonLayer( + polygons: + /* Polyline( points: line, strokeWidth: radius * 2, useStrokeWidthInMeter: true, - color: color.toColor(), - ), - ], + color: color.toColor().withValues(alpha: 0.7), + strokeJoin: StrokeJoin.miter, + strokeCap: StrokeCap.square, + ),*/ + region + .toOutlines(1) + .map( + (o) => Polygon( + points: o, + color: color.toColor().withValues(alpha: 0.7), + ), + ) + .toList(growable: false), ), CustomPolygonRegion(:final outline) => PolygonLayer( polygons: [ Polygon( points: outline, - color: color.toColor(), + color: color.toColor().withValues(alpha: 0.7), ), ], ), @@ -71,18 +81,37 @@ class RegionShape extends StatelessWidget { }, if (provider.currentConstructingCoordinates.isNotEmpty) if (provider.currentRegionType == RegionType.line) - PolylineLayer( + /* PolylineLayer( polylines: [ Polyline( points: [ ...provider.currentConstructingCoordinates, provider.currentNewPointPos, ], - color: Colors.white.withOpacity(2 / 3), + color: Colors.white.withValues(alpha: 2 / 3), strokeWidth: provider.lineRadius * 2, + strokeJoin: StrokeJoin.miter, + strokeCap: StrokeCap.square, useStrokeWidthInMeter: true, ), ], + )*/ + PolygonLayer( + polygons: LineRegion( + [ + ...provider.currentConstructingCoordinates, + provider.currentNewPointPos, + ], + provider.lineRadius * 2, + ) + .toOutlines(1) + .map( + (o) => Polygon( + points: o, + color: Colors.white.withValues(alpha: 2 / 3), + ), + ) + .toList(growable: false), ) else PolygonLayer( @@ -132,7 +161,7 @@ class RegionShape extends StatelessWidget { color: Theme.of(context) .colorScheme .surface - .withOpacity(0.5), + .withValues(alpha: 0.5), ), ], ), 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 a5d29e86..52d5482a 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'dart:math'; -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'; @@ -21,7 +20,7 @@ import 'components/region_selection/region_shape.dart'; enum MapViewMode { standard, - regionSelect, + downloadRegion, } class MapView extends StatefulWidget { @@ -63,6 +62,10 @@ class _MapViewState extends State with TickerProviderStateMixin { }, ); + bool _isInRegionSelectMode() => + widget.mode == MapViewMode.downloadRegion && + !context.read().isDownloadSetupPanelVisible; + @override Widget build(BuildContext context) { final mapOptions = MapOptions( @@ -81,7 +84,7 @@ class _MapViewState extends State with TickerProviderStateMixin { keepAlive: true, backgroundColor: const Color(0xFFaad3df), onTap: (_, __) { - if (widget.mode != MapViewMode.regionSelect) return; + if (!_isInRegionSelectMode()) return; final provider = context.read(); @@ -125,15 +128,15 @@ class _MapViewState extends State with TickerProviderStateMixin { } }, onSecondaryTap: (_, __) { - if (widget.mode != MapViewMode.regionSelect) return; + if (!_isInRegionSelectMode()) return; context.read().removeLastCoordinate(); }, onLongPress: (_, __) { - if (widget.mode != MapViewMode.regionSelect) return; + if (!_isInRegionSelectMode()) return; context.read().removeLastCoordinate(); }, onPointerHover: (evt, point) { - if (widget.mode != MapViewMode.regionSelect) return; + if (!_isInRegionSelectMode()) return; final provider = context.read(); @@ -158,7 +161,7 @@ class _MapViewState extends State with TickerProviderStateMixin { } }, onPositionChanged: (position, _) { - if (widget.mode != MapViewMode.regionSelect) return; + if (!_isInRegionSelectMode()) return; final provider = context.read(); @@ -252,7 +255,7 @@ class _MapViewState extends State with TickerProviderStateMixin { ); if (behaviour == null) return null; return MapEntry(e, behaviour); - }).whereNotNull(), + }).nonNulls, ); final attribution = RichAttributionWidget( @@ -314,7 +317,7 @@ class _MapViewState extends State with TickerProviderStateMixin { otherStoresStrategy != null, ), ), - if (widget.mode == MapViewMode.regionSelect) ...[ + if (widget.mode == MapViewMode.downloadRegion) ...[ const RegionShape(), const CustomPolygonSnappingIndicator(), ], @@ -336,6 +339,9 @@ class _MapViewState extends State with TickerProviderStateMixin { MouseRegion( opaque: false, cursor: widget.mode == MapViewMode.standard || + context.select( + (p) => p.isDownloadSetupPanelVisible, + ) || context.select( (p) => p.regionSelectionMethod, @@ -349,7 +355,10 @@ class _MapViewState extends State with TickerProviderStateMixin { : SystemMouseCursors.precise, child: map, ), - if (widget.mode == MapViewMode.regionSelect && + if (widget.mode == MapViewMode.downloadRegion && + !context.select( + (p) => p.isDownloadSetupPanelVisible, + ) && context.select( (p) => p.regionSelectionMethod, diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart new file mode 100644 index 00000000..e0e1920c --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; +import '../../layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart'; +import '../../layouts/bottom_sheet/utils/tab_header.dart'; +import '../region_selection/components/shape_selector/shape_selector.dart'; +import '../region_selection/components/sub_regions_list/sub_regions_list.dart'; + +class DownloadConfigurationViewBottomSheet extends StatelessWidget { + const DownloadConfigurationViewBottomSheet({super.key}); + + @override + Widget build(BuildContext context) => CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + slivers: const [ + BottomSheetTopSpacer(), + TabHeader(title: 'Download Configuration'), + SliverToBoxAdapter(child: SizedBox(height: 6)), + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: ShapeSelector(), + ), + ), + SliverToBoxAdapter(child: Divider(height: 24)), + SliverToBoxAdapter(child: SizedBox(height: 6)), + SubRegionsList(), + SliverToBoxAdapter(child: SizedBox(height: 6)), + ], + ); +} 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 new file mode 100644 index 00000000..bf685cef --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_side.dart @@ -0,0 +1,113 @@ +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'; + +class DownloadConfigurationViewSide extends StatelessWidget { + const DownloadConfigurationViewSide({super.key}); + + @override + Widget build(BuildContext context) { + final hasConstructedRegions = context.select( + (p) => p.constructedRegions.isNotEmpty, + ); + + return Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(4), + child: IconButton( + onPressed: () { + context + .read() + .isDownloadSetupPanelVisible = false; + }, + icon: const Icon(Icons.arrow_back), + tooltip: 'Return to selection', + ), + ), + ), + 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: [ + CustomScrollView( + slivers: [ + SliverPadding( + padding: hasConstructedRegions + ? const EdgeInsets.only(top: 16, bottom: 16 + 52) + : EdgeInsets.zero, + sliver: hasConstructedRegions + ? const SubRegionsList() + : const NoSubRegions(), + ), + ], + ), + PositionedDirectional( + end: 8, + bottom: 8, + child: IgnorePointer( + ignoring: !hasConstructedRegions, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + opacity: hasConstructedRegions ? 1 : 0, + 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 SizedBox(height: 16), + ], + ); + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart index 5e3e956f..7bf81996 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart @@ -89,7 +89,7 @@ class BrowseStoreStrategySelector extends StatelessWidget { duration: const Duration(milliseconds: 100), decoration: BoxDecoration( color: BrowseStoreStrategySelector._unspecifiedSelectorColor - .withOpacity(0.75), + .withValues(alpha: 0.75), borderRadius: BorderRadius.circular(99), ), width: isUsingUnselectedStrategy diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart index 35414e59..e67928c3 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart @@ -204,7 +204,7 @@ class _StoreTileState extends State { color: Theme.of(context) .colorScheme .error - .withOpacity(0.75), + .withValues(alpha: 0.75), borderRadius: BorderRadius.circular(12), ), child: const Row( diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart index 2792e8be..865ffa36 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart @@ -9,6 +9,7 @@ import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../../../../../../../shared/state/region_selection_provider.dart'; +import '../shared/to_config_method.dart'; part 'components/animated_visibility_icon_button.dart'; @@ -232,7 +233,7 @@ class _ShapeSelectorState extends State { tooltip: 'Complete region', isVisible: provider.currentRegionType == RegionType.line && - provider.constructedRegions.length <= 1, + provider.constructedRegions.isEmpty, ), ], ), @@ -246,7 +247,7 @@ class _ShapeSelectorState extends State { void _completeRegion() { _addSubRegion(); - context.read().isDownloadSetupPanelVisible = true; + moveToDownloadConfigView(context); } void _addSubRegion() { 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 new file mode 100644 index 00000000..8150bd7c --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shared/to_config_method.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../shared/state/general_provider.dart'; +import '../../../../../../../shared/state/region_selection_provider.dart'; + +void moveToDownloadConfigView(BuildContext context) { + final regionSelectionProvider = context.read(); + + final bounds = LatLngBounds.fromPoints( + regionSelectionProvider.constructedRegions.keys + .elementAt(0) + .toOutline() + .toList(growable: false), + ); + for (final region + in regionSelectionProvider.constructedRegions.keys.skip(1)) { + bounds.extendBounds( + LatLngBounds.fromPoints(region.toOutline().toList(growable: false)), + ); + } + context.read().animatedMapController.animatedFitCamera( + cameraFit: + CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(16)), + ); + + regionSelectionProvider.isDownloadSetupPanelVisible = true; +} diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/e/adjust_zoom_lvls_pane.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/e/adjust_zoom_lvls_pane.dart deleted file mode 100644 index fc69fec0..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/e/adjust_zoom_lvls_pane.dart +++ /dev/null @@ -1,58 +0,0 @@ - -/* - -class AdjustZoomLvlsPane extends StatelessWidget { - const AdjustZoomLvlsPane({ - super.key, - required this.layoutDirection, - }); - - final Axis layoutDirection; - - @override - Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => Flex( - direction: layoutDirection, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.zoom_in), - const SizedBox.square(dimension: 4), - Text(provider.maxZoom.toString().padLeft(2, '0')), - Expanded( - child: Padding( - padding: layoutDirection == Axis.vertical - ? const EdgeInsets.only(bottom: 6, top: 6) - : const EdgeInsets.only(left: 6, right: 6), - child: RotatedBox( - quarterTurns: layoutDirection == Axis.vertical ? 3 : 2, - child: SliderTheme( - data: SliderThemeData( - trackShape: _CustomSliderTrackShape(), - showValueIndicator: ShowValueIndicator.never, - ), - child: RangeSlider( - values: RangeValues( - provider.minZoom.toDouble(), - provider.maxZoom.toDouble(), - ), - onChanged: (v) { - provider - ..minZoom = v.start.round() - ..maxZoom = v.end.round(); - }, - max: 22, - divisions: 22, - ), - ), - ), - ), - ), - Text(provider.minZoom.toString().padLeft(2, '0')), - const SizedBox.square(dimension: 4), - const Icon(Icons.zoom_out), - ], - ), - ); -} -*/ diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/e/custom_slider_track_shape.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/e/custom_slider_track_shape.dart deleted file mode 100644 index 7b65063e..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/e/custom_slider_track_shape.dart +++ /dev/null @@ -1,19 +0,0 @@ -// From https://stackoverflow.com/a/65662764/11846040 -import 'package:flutter/material.dart'; - -class CustomSliderTrackShape extends RoundedRectSliderTrackShape { - @override - Rect getPreferredRect({ - required RenderBox parentBox, - Offset offset = Offset.zero, - required SliderThemeData sliderTheme, - bool isEnabled = false, - bool isDiscrete = false, - }) { - final trackHeight = sliderTheme.trackHeight; - final trackLeft = offset.dx; - final trackTop = offset.dy + (parentBox.size.height - trackHeight!) / 2; - final trackWidth = parentBox.size.width; - return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); - } -} diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart index a0e3badc..aa14512f 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart @@ -27,14 +27,6 @@ class RegionSelectionViewBottomSheet extends StatelessWidget { SliverToBoxAdapter(child: SizedBox(height: 6)), SubRegionsList(), SliverToBoxAdapter(child: SizedBox(height: 6)), - SliverToBoxAdapter(child: Divider(height: 24)), - SliverPadding( - padding: EdgeInsets.symmetric(horizontal: 16), - sliver: SliverToBoxAdapter( - child: ShapeSelector(), - ), - ), - SliverToBoxAdapter(child: SizedBox(height: 16)), ], ); } diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart index 4d217a0a..e89b6690 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; import '../../layouts/side/components/panel.dart'; import 'components/shape_selector/shape_selector.dart'; +import 'components/shared/to_config_method.dart'; import 'components/sub_regions_list/components/no_sub_regions.dart'; import 'components/sub_regions_list/sub_regions_list.dart'; @@ -63,18 +64,17 @@ class RegionSelectionViewSide extends StatelessWidget { child: Row( children: [ IconButton( - onPressed: () { - context - .read() - .clearConstructedRegions(); - }, + onPressed: () => context + .read() + .clearConstructedRegions(), icon: const Icon(Icons.delete_forever), ), const SizedBox(width: 8), SizedBox( height: double.infinity, child: FilledButton.icon( - onPressed: () {}, + onPressed: () => + moveToDownloadConfigView(context), label: const Text('Configure Download'), icon: const Icon(Icons.tune), ), @@ -91,9 +91,6 @@ class RegionSelectionViewSide extends StatelessWidget { ), ), ), - //const SizedBox(height: 16), - /*const SideViewPanel(child: ShapeSelector()), - const SizedBox(height: 16),*/ ], ); } 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 c88acaba..adc17527 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 @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +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/region_selection/region_selection_view_bottom_sheet.dart'; import 'components/delayed_frame_attached_dependent_builder.dart'; @@ -104,7 +107,11 @@ class _SecondaryViewBottomSheetState extends State { width: double.infinity, child: switch (widget.selectedTab) { 0 => const HomeViewBottomSheet(), - 1 => const RegionSelectionViewBottomSheet(), + 1 => context.select( + (p) => p.isDownloadSetupPanelVisible, + ) + ? const DownloadConfigurationViewBottomSheet() + : const RegionSelectionViewBottomSheet(), _ => Placeholder(key: ValueKey(widget.selectedTab)), }, ), @@ -141,7 +148,7 @@ class _SecondaryViewBottomSheetState extends State { color: Theme.of(context) .colorScheme .onSurfaceVariant - .withOpacity(0.4), + .withValues(alpha: 0.4), ), ), ), diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart index 34eceafe..bb8f0f48 100644 --- a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart @@ -13,13 +13,10 @@ class BottomSheetScrollableProvider extends InheritedWidget { Widget build(BuildContext context) => child; - static ScrollController? maybeInnerScrollControllerOf(BuildContext context) => - context - .dependOnInheritedWidgetOfExactType() - ?.innerScrollController; - static ScrollController innerScrollControllerOf(BuildContext context) => - maybeInnerScrollControllerOf(context)!; + context + .dependOnInheritedWidgetOfExactType()! + .innerScrollController; static DraggableScrollableController? maybeOuterScrollControllerOf( BuildContext context, 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 99580bc0..f93a6985 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 @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../../../shared/state/region_selection_provider.dart'; +import '../../contents/download_configuration/download_configuration_view_side.dart'; import '../../contents/home/home_view_side.dart'; import '../../contents/region_selection/region_selection_view_side.dart'; @@ -41,7 +44,11 @@ class SecondaryViewSide extends StatelessWidget { ), child: switch (selectedTab) { 0 => HomeViewSide(constraints: constraints), - 1 => const RegionSelectionViewSide(), + 1 => context.select( + (p) => p.isDownloadSetupPanelVisible, + ) + ? const DownloadConfigurationViewSide() + : const RegionSelectionViewSide(), _ => Placeholder(key: ValueKey(selectedTab)), }, ), diff --git a/example/lib/src/screens/configure_download/components/numerical_input_row.dart b/example/lib/src/screens/old/configure_download/components/numerical_input_row.dart similarity index 98% rename from example/lib/src/screens/configure_download/components/numerical_input_row.dart rename to example/lib/src/screens/old/configure_download/components/numerical_input_row.dart index 0c5c69cd..33994f31 100644 --- a/example/lib/src/screens/configure_download/components/numerical_input_row.dart +++ b/example/lib/src/screens/old/configure_download/components/numerical_input_row.dart @@ -70,7 +70,7 @@ class _NumericalInputRowState extends State { Icons.lock, color: currentValue == widget.max ? Colors.amber - : Colors.white.withOpacity(0.2), + : Colors.white.withValues(alpha: 0.2), ), ), const SizedBox(width: 16), diff --git a/example/lib/src/screens/configure_download/components/options_pane.dart b/example/lib/src/screens/old/configure_download/components/options_pane.dart similarity index 95% rename from example/lib/src/screens/configure_download/components/options_pane.dart rename to example/lib/src/screens/old/configure_download/components/options_pane.dart index 1993455b..8aa9cd08 100644 --- a/example/lib/src/screens/configure_download/components/options_pane.dart +++ b/example/lib/src/screens/old/configure_download/components/options_pane.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../shared/misc/exts/interleave.dart'; +import '../../../../shared/misc/exts/interleave.dart'; class OptionsPane extends StatelessWidget { const OptionsPane({ diff --git a/example/lib/src/screens/configure_download/components/region_information.dart b/example/lib/src/screens/old/configure_download/components/region_information.dart similarity index 100% rename from example/lib/src/screens/configure_download/components/region_information.dart rename to example/lib/src/screens/old/configure_download/components/region_information.dart diff --git a/example/lib/src/screens/configure_download/components/start_download_button.dart b/example/lib/src/screens/old/configure_download/components/start_download_button.dart similarity index 98% rename from example/lib/src/screens/configure_download/components/start_download_button.dart rename to example/lib/src/screens/old/configure_download/components/start_download_button.dart index 73f65e4b..d4df7ed7 100644 --- a/example/lib/src/screens/configure_download/components/start_download_button.dart +++ b/example/lib/src/screens/old/configure_download/components/start_download_button.dart @@ -5,7 +5,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../shared/misc/store_metadata_keys.dart'; +import '../../../../shared/misc/store_metadata_keys.dart'; import '../../download/download.dart'; import '../state/configure_download_provider.dart'; diff --git a/example/lib/src/screens/configure_download/components/store_selector.dart b/example/lib/src/screens/old/configure_download/components/store_selector.dart similarity index 100% rename from example/lib/src/screens/configure_download/components/store_selector.dart rename to example/lib/src/screens/old/configure_download/components/store_selector.dart diff --git a/example/lib/src/screens/configure_download/configure_download.dart b/example/lib/src/screens/old/configure_download/configure_download.dart similarity index 97% rename from example/lib/src/screens/configure_download/configure_download.dart rename to example/lib/src/screens/old/configure_download/configure_download.dart index 6ca36880..817a5f24 100644 --- a/example/lib/src/screens/configure_download/configure_download.dart +++ b/example/lib/src/screens/old/configure_download/configure_download.dart @@ -3,8 +3,8 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../shared/misc/exts/interleave.dart'; -import '../../shared/state/region_selection_provider.dart'; +import '../../../shared/misc/exts/interleave.dart'; +import '../../../shared/state/region_selection_provider.dart'; import 'components/numerical_input_row.dart'; import 'components/options_pane.dart'; import 'components/region_information.dart'; diff --git a/example/lib/src/screens/configure_download/state/configure_download_provider.dart b/example/lib/src/screens/old/configure_download/state/configure_download_provider.dart similarity index 100% rename from example/lib/src/screens/configure_download/state/configure_download_provider.dart rename to example/lib/src/screens/old/configure_download/state/configure_download_provider.dart diff --git a/example/lib/src/screens/download/components/confirm_cancellation_dialog.dart b/example/lib/src/screens/old/download/components/confirm_cancellation_dialog.dart similarity index 100% rename from example/lib/src/screens/download/components/confirm_cancellation_dialog.dart rename to example/lib/src/screens/old/download/components/confirm_cancellation_dialog.dart diff --git a/example/lib/src/screens/download/components/main_statistics.dart b/example/lib/src/screens/old/download/components/main_statistics.dart similarity index 100% rename from example/lib/src/screens/download/components/main_statistics.dart rename to example/lib/src/screens/old/download/components/main_statistics.dart diff --git a/example/lib/src/screens/download/components/multi_linear_progress_indicator.dart b/example/lib/src/screens/old/download/components/multi_linear_progress_indicator.dart similarity index 100% rename from example/lib/src/screens/download/components/multi_linear_progress_indicator.dart rename to example/lib/src/screens/old/download/components/multi_linear_progress_indicator.dart diff --git a/example/lib/src/screens/download/components/stat_display.dart b/example/lib/src/screens/old/download/components/stat_display.dart similarity index 100% rename from example/lib/src/screens/download/components/stat_display.dart rename to example/lib/src/screens/old/download/components/stat_display.dart diff --git a/example/lib/src/screens/download/components/stats_table.dart b/example/lib/src/screens/old/download/components/stats_table.dart similarity index 100% rename from example/lib/src/screens/download/components/stats_table.dart rename to example/lib/src/screens/old/download/components/stats_table.dart diff --git a/example/lib/src/screens/download/download.dart b/example/lib/src/screens/old/download/download.dart similarity index 99% rename from example/lib/src/screens/download/download.dart rename to example/lib/src/screens/old/download/download.dart index f6f6e028..cafc78e5 100644 --- a/example/lib/src/screens/download/download.dart +++ b/example/lib/src/screens/old/download/download.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import '../../shared/misc/exts/size_formatter.dart'; +import '../../../shared/misc/exts/size_formatter.dart'; import 'components/confirm_cancellation_dialog.dart'; import 'components/main_statistics.dart'; import 'components/multi_linear_progress_indicator.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index cbe8229f..1d63b7b5 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -933,7 +933,7 @@ Future _worker( .map( (s) => storesObjectsForRelations[s.name], ) - .whereNotNull(), + .nonNulls, ), mode: PutMode.insert, ); From ef8d0e56540fdba9b90377d25f0a3ba56156957c Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 30 Sep 2024 22:28:41 +0100 Subject: [PATCH 57/97] Improved example application Discovered unintended behaviour --- example/lib/main.dart | 6 +- .../debugging_tile_builder.dart | 17 +- .../debugging_tile_builder/info_display.dart | 2 + .../config_options/config_options.dart | 119 +++++++++++++ .../download_configuration_view_side.dart | 164 ++++++++---------- .../components/numerical_input_row.dart | 13 +- .../components/start_download_button.dart | 6 +- .../components/store_selector.dart | 6 +- .../configure_download.dart | 14 +- .../confirm_cancellation_dialog.dart | 4 +- .../download/components/main_statistics.dart | 4 +- .../download_configuration_provider.dart} | 36 +++- .../state/region_selection_provider.dart | 35 ---- .../tile_provider/tile_provider.dart | 2 + 14 files changed, 271 insertions(+), 157 deletions(-) create mode 100644 example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/config_options.dart rename example/lib/src/{screens/old/configure_download/state/configure_download_provider.dart => shared/state/download_configuration_provider.dart} (64%) diff --git a/example/lib/main.dart b/example/lib/main.dart index cfa54ae0..5dda1505 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -9,7 +9,7 @@ import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/main/main.dart'; import 'src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart'; import 'src/screens/old/configure_download/configure_download.dart'; -import 'src/screens/old/configure_download/state/configure_download_provider.dart'; +import 'src/shared/state/download_configuration_provider.dart'; import 'src/screens/old/download/download.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; @@ -118,8 +118,8 @@ class _AppContainer extends StatelessWidget { lazy: true, ), ChangeNotifierProvider( - create: (_) => ConfigureDownloadProvider(), - lazy: true, + create: (_) => DownloadConfigurationProvider(), + //lazy: true, ), ], child: MaterialApp( diff --git a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart index 05175ad6..ebe8163f 100644 --- a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart +++ b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart @@ -5,7 +5,7 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; part 'info_display.dart'; part 'result_dialogs.dart'; -class DebuggingTileBuilder extends StatelessWidget { +class DebuggingTileBuilder extends StatefulWidget { const DebuggingTileBuilder({ super.key, required this.tileWidget, @@ -19,6 +19,11 @@ class DebuggingTileBuilder extends StatelessWidget { final ValueNotifier tileLoadingDebugger; final bool usingFMTC; + @override + State createState() => _DebuggingTileBuilderState(); +} + +class _DebuggingTileBuilderState extends State { @override Widget build(BuildContext context) => Stack( fit: StackFit.expand, @@ -33,9 +38,9 @@ class DebuggingTileBuilder extends StatelessWidget { color: Colors.white.withValues(alpha: 0.5), ), position: DecorationPosition.foreground, - child: tileWidget, + child: widget.tileWidget, ), - if (!usingFMTC) + if (!widget.usingFMTC) const OverflowBox( child: Padding( padding: EdgeInsets.all(6), @@ -51,10 +56,10 @@ class DebuggingTileBuilder extends StatelessWidget { ) else ValueListenableBuilder( - valueListenable: tileLoadingDebugger, + valueListenable: widget.tileLoadingDebugger, builder: (context, value, _) { - if (value[tile.coordinates] case final info?) { - return _ResultDisplay(tile: tile, fmtcResult: info); + if (value[widget.tile.coordinates] case final info?) { + return _ResultDisplay(tile: widget.tile, fmtcResult: info); } return const Center( diff --git a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart index b131b201..9a46d5ae 100644 --- a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart +++ b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart @@ -65,6 +65,8 @@ class _ResultDisplay extends StatelessWidget { ), const SizedBox(width: 8), FutureBuilder( + // TODO: Factor out of build to reduce re-futures + // or fix rebuild issue future: fmtcResult.storesWriteResult, builder: (context, snapshot) => IconButton.filledTonal( onPressed: snapshot.data != null 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 new file mode 100644 index 00000000..a5f72f1b --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/config_options.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../shared/state/download_configuration_provider.dart'; +import '../../../../../../../shared/state/region_selection_provider.dart'; + +class ConfigOptions extends StatefulWidget { + const ConfigOptions({super.key}); + + @override + State createState() => _ConfigOptionsState(); +} + +class _ConfigOptionsState extends State { + @override + Widget build(BuildContext context) { + final minZoom = + context.select((p) => p.minZoom); + final maxZoom = + context.select((p) => p.maxZoom); + final parallelThreads = context + .select((p) => p.parallelThreads); + final rateLimit = + context.select((p) => p.rateLimit); + final maxBufferLength = context + .select((p) => p.maxBufferLength); + + return SingleChildScrollView( + child: Column( + children: [ + Row( + children: [ + const Tooltip(message: 'Zoom Levels', child: Icon(Icons.search)), + const SizedBox(width: 8), + Expanded( + child: RangeSlider( + values: RangeValues(minZoom.toDouble(), maxZoom.toDouble()), + labels: RangeLabels(minZoom.toString(), maxZoom.toString()), + max: 20, + divisions: 20, + onChanged: (r) => + context.read() + ..minZoom = r.start.toInt() + ..maxZoom = r.end.toInt(), + ), + ), + ], + ), + const Divider(), + Row( + children: [ + const Tooltip( + message: 'Parallel Threads', + child: Icon(Icons.call_split), + ), + const SizedBox(width: 6), + Expanded( + child: Slider( + value: parallelThreads.toDouble(), + label: '$parallelThreads threads', + max: 10, + divisions: 10, + onChanged: (r) => context + .read() + .parallelThreads = r.toInt(), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Tooltip( + message: 'Rate Limit', + child: Icon(Icons.speed), + ), + const SizedBox(width: 6), + Expanded( + child: Slider( + min: 1, + value: rateLimit.toDouble(), + label: '$rateLimit tps', + max: 200, + divisions: 199, + onChanged: (r) => context + .read() + .rateLimit = r.toInt(), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Tooltip( + message: 'Max Buffer Length', + child: Icon(Icons.memory), + ), + const SizedBox(width: 6), + Expanded( + child: Slider( + value: maxBufferLength.toDouble(), + label: maxBufferLength == 0 + ? 'Disabled' + : '$maxBufferLength tiles', + max: 1000, + divisions: 1000, + onChanged: (r) => context + .read() + .maxBufferLength = r.toInt(), + ), + ), + ], + ), + ], + ), + ); + } +} 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 bf685cef..08980f94 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 @@ -4,110 +4,98 @@ 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 'components/config_options/config_options.dart'; class DownloadConfigurationViewSide extends StatelessWidget { const DownloadConfigurationViewSide({super.key}); @override - Widget build(BuildContext context) { - final hasConstructedRegions = context.select( - (p) => p.constructedRegions.isNotEmpty, - ); - - return Column( - children: [ - Align( - alignment: Alignment.centerLeft, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(99), - color: Theme.of(context).colorScheme.surface, - ), - padding: const EdgeInsets.all(4), - child: IconButton( - onPressed: () { - context - .read() - .isDownloadSetupPanelVisible = false; - }, - icon: const Icon(Icons.arrow_back), - tooltip: 'Return to selection', + Widget build(BuildContext context) => Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(4), + child: IconButton( + onPressed: () { + context + .read() + .isDownloadSetupPanelVisible = false; + }, + icon: const Icon(Icons.arrow_back), + tooltip: 'Return to selection', + ), ), ), - ), - 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: [ - CustomScrollView( - slivers: [ - SliverPadding( - padding: hasConstructedRegions - ? const EdgeInsets.only(top: 16, bottom: 16 + 52) - : EdgeInsets.zero, - sliver: hasConstructedRegions - ? const SubRegionsList() - : const NoSubRegions(), - ), - ], - ), - PositionedDirectional( - end: 8, - bottom: 8, - child: IgnorePointer( - ignoring: !hasConstructedRegions, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - opacity: hasConstructedRegions ? 1 : 0, - 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( + 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() - .isDownloadSetupPanelVisible = true, - label: const Text('Configure Download'), - icon: const Icon(Icons.tune), + .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 SizedBox(height: 16), - ], - ); - } + const SizedBox(height: 16), + ], + ); } diff --git a/example/lib/src/screens/old/configure_download/components/numerical_input_row.dart b/example/lib/src/screens/old/configure_download/components/numerical_input_row.dart index 33994f31..237d165e 100644 --- a/example/lib/src/screens/old/configure_download/components/numerical_input_row.dart +++ b/example/lib/src/screens/old/configure_download/components/numerical_input_row.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../state/configure_download_provider.dart'; +import '../../../../shared/state/download_configuration_provider.dart'; class NumericalInputRow extends StatefulWidget { const NumericalInputRow({ @@ -18,11 +18,12 @@ class NumericalInputRow extends StatefulWidget { final String label; final String suffixText; - final int Function(ConfigureDownloadProvider provider) value; + final int Function(DownloadConfigurationProvider provider) value; final int min; final int? max; final int? maxEligibleTilesPreview; - final void Function(ConfigureDownloadProvider provider, int value) onChanged; + final void Function(DownloadConfigurationProvider provider, int value) + onChanged; @override State createState() => _NumericalInputRowState(); @@ -33,7 +34,7 @@ class _NumericalInputRowState extends State { @override Widget build(BuildContext context) => - Selector( + Selector( selector: (context, provider) => widget.value(provider), builder: (context, currentValue, _) { tec ??= TextEditingController(text: currentValue.toString()); @@ -52,7 +53,7 @@ class _NumericalInputRowState extends State { onPressed: currentValue > widget.maxEligibleTilesPreview! ? () { widget.onChanged( - context.read(), + context.read(), widget.maxEligibleTilesPreview!, ); tec!.text = widget.maxEligibleTilesPreview.toString(); @@ -93,7 +94,7 @@ class _NumericalInputRowState extends State { ), ], onChanged: (newVal) => widget.onChanged( - context.read(), + context.read(), int.tryParse(newVal) ?? currentValue, ), ), 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 d4df7ed7..8219ce10 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 @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import '../../../../shared/misc/store_metadata_keys.dart'; import '../../download/download.dart'; -import '../state/configure_download_provider.dart'; +import '../../../../shared/state/download_configuration_provider.dart'; class StartDownloadButton extends StatelessWidget { const StartDownloadButton({ @@ -21,7 +21,7 @@ class StartDownloadButton extends StatelessWidget { @override Widget build(BuildContext context) => - Selector( + Selector( selector: (context, provider) => provider.selectedStore, builder: (context, selectedStore, child) { final enabled = selectedStore != null && maxTiles != null; @@ -43,7 +43,7 @@ class StartDownloadButton extends StatelessWidget { FloatingActionButton.extended( onPressed: () async { final configureDownloadProvider = - context.read(); + context.read(); if (!await configureDownloadProvider .selectedStore!.manage.ready && 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 574f985e..c7f1d070 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 @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../state/configure_download_provider.dart'; +import '../../../../shared/state/download_configuration_provider.dart'; class StoreSelector extends StatefulWidget { const StoreSelector({super.key}); @@ -18,7 +18,7 @@ class _StoreSelectorState extends State { const Text('Store'), const Spacer(), IntrinsicWidth( - child: Selector( + child: Selector( selector: (context, provider) => provider.selectedStore, builder: (context, selectedStore, _) => FutureBuilder>( @@ -41,7 +41,7 @@ class _StoreSelectorState extends State { return DropdownButton( items: items, onChanged: (store) => context - .read() + .read() .selectedStore = store, value: selectedStore, hint: Text(text), diff --git a/example/lib/src/screens/old/configure_download/configure_download.dart b/example/lib/src/screens/old/configure_download/configure_download.dart index 817a5f24..caafbd34 100644 --- a/example/lib/src/screens/old/configure_download/configure_download.dart +++ b/example/lib/src/screens/old/configure_download/configure_download.dart @@ -10,7 +10,7 @@ import 'components/options_pane.dart'; import 'components/region_information.dart'; import 'components/start_download_button.dart'; import 'components/store_selector.dart'; -import 'state/configure_download_provider.dart'; +import '../../../shared/state/download_configuration_provider.dart'; class ConfigureDownloadPopup extends StatefulWidget { const ConfigureDownloadPopup({super.key}); @@ -108,12 +108,12 @@ class _ConfigureDownloadPopupState extends State { const Text('Skip Existing Tiles'), const Spacer(), Switch.adaptive( - value: - context.select( + value: context + .select( (provider) => provider.skipExistingTiles, ), onChanged: (val) => context - .read() + .read() .skipExistingTiles = val, activeColor: Theme.of(context).colorScheme.primary, ), @@ -125,12 +125,12 @@ class _ConfigureDownloadPopupState extends State { const Text('Skip Sea Tiles'), const Spacer(), Switch.adaptive( - value: - context.select( + value: context + .select( (provider) => provider.skipSeaTiles, ), onChanged: (val) => context - .read() + .read() .skipSeaTiles = val, activeColor: Theme.of(context).colorScheme.primary, ), 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 0f0c9965..ccc41e35 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,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../configure_download/state/configure_download_provider.dart'; +import '../../../../shared/state/download_configuration_provider.dart'; class ConfirmCancellationDialog extends StatefulWidget { const ConfirmCancellationDialog({super.key}); @@ -31,7 +31,7 @@ class _ConfirmCancellationDialogState extends State { onPressed: () async { setState(() => isCancelling = true); await context - .read() + .read() .selectedStore! .download .cancel(); 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 850c1f3b..078b44a1 100644 --- a/example/lib/src/screens/old/download/components/main_statistics.dart +++ b/example/lib/src/screens/old/download/components/main_statistics.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../configure_download/state/configure_download_provider.dart'; +import '../../../../shared/state/download_configuration_provider.dart'; import 'stat_display.dart'; class MainStatistics extends StatefulWidget { @@ -88,7 +88,7 @@ class _MainStatisticsState extends State { const SizedBox(height: 24), if (!(widget.download?.isComplete ?? false)) RepaintBoundary( - child: Selector( + child: Selector( selector: (context, provider) => provider.selectedStore, builder: (context, selectedStore, _) => Row( mainAxisSize: MainAxisSize.min, diff --git a/example/lib/src/screens/old/configure_download/state/configure_download_provider.dart b/example/lib/src/shared/state/download_configuration_provider.dart similarity index 64% rename from example/lib/src/screens/old/configure_download/state/configure_download_provider.dart rename to example/lib/src/shared/state/download_configuration_provider.dart index 4981bf35..6430b50e 100644 --- a/example/lib/src/screens/old/configure_download/state/configure_download_provider.dart +++ b/example/lib/src/shared/state/download_configuration_provider.dart @@ -1,15 +1,47 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -class ConfigureDownloadProvider extends ChangeNotifier { +class DownloadConfigurationProvider extends ChangeNotifier { static const defaultValues = ( + minZoom: 0, + maxZoom: 14, + startTile: 1, + endTile: null, parallelThreads: 3, rateLimit: 200, - maxBufferLength: 200, + maxBufferLength: 0, skipExistingTiles: false, skipSeaTiles: true, ); + int _minZoom = defaultValues.minZoom; + int get minZoom => _minZoom; + set minZoom(int newNum) { + _minZoom = newNum; + notifyListeners(); + } + + int _maxZoom = defaultValues.maxZoom; + int get maxZoom => _maxZoom; + set maxZoom(int newNum) { + _maxZoom = newNum; + notifyListeners(); + } + + int _startTile = defaultValues.startTile; + int get startTile => _startTile; + set startTile(int newNum) { + _startTile = newNum; + notifyListeners(); + } + + int? _endTile = defaultValues.endTile; + int? get endTile => _endTile; + set endTile(int? newNum) { + _endTile = endTile; + notifyListeners(); + } + int _parallelThreads = defaultValues.parallelThreads; int get parallelThreads => _parallelThreads; set parallelThreads(int newNum) { diff --git a/example/lib/src/shared/state/region_selection_provider.dart b/example/lib/src/shared/state/region_selection_provider.dart index 1b31e344..d16248b2 100644 --- a/example/lib/src/shared/state/region_selection_provider.dart +++ b/example/lib/src/shared/state/region_selection_provider.dart @@ -122,41 +122,6 @@ class RegionSelectionProvider extends ChangeNotifier { notifyListeners(); } - bool _openAdjustZoomLevelsSlider = false; - bool get openAdjustZoomLevelsSlider => _openAdjustZoomLevelsSlider; - set openAdjustZoomLevelsSlider(bool newState) { - _openAdjustZoomLevelsSlider = newState; - notifyListeners(); - } - - int _minZoom = 0; - int get minZoom => _minZoom; - set minZoom(int newNum) { - _minZoom = newNum; - notifyListeners(); - } - - int _maxZoom = 16; - int get maxZoom => _maxZoom; - set maxZoom(int newNum) { - _maxZoom = newNum; - notifyListeners(); - } - - int _startTile = 1; - int get startTile => _startTile; - set startTile(int newNum) { - _startTile = newNum; - notifyListeners(); - } - - int? _endTile; - int? get endTile => _endTile; - set endTile(int? newNum) { - _endTile = endTile; - notifyListeners(); - } - bool _isDownloadSetupPanelVisible = false; bool get isDownloadSetupPanelVisible => _isDownloadSetupPanelVisible; set isDownloadSetupPanelVisible(bool newState) { diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index c4ee90c7..e6648850 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -81,6 +81,8 @@ class FMTCTileProvider extends TileProvider { /// Stores not included will not be used by default. However, /// [otherStoresStrategy] determines whether & how all other unspecified /// stores should be used. + /// + // TODO: Accept null values to exempt from [otherStoresStrategy] final Map storeNames; /// The behaviour of all other stores not specified in [storeNames] From 9403e8ff22704967f2ed65af6d3c4732b995344d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 2 Oct 2024 19:22:58 +0100 Subject: [PATCH 58/97] Example app experimentation --- .../download_progress/download_progress.dart | 193 ++++++++++++++++++ .../src/screens/main/map_view/map_view.dart | 63 +++--- 2 files changed, 228 insertions(+), 28 deletions(-) create mode 100644 example/lib/src/screens/main/map_view/components/download_progress/download_progress.dart diff --git a/example/lib/src/screens/main/map_view/components/download_progress/download_progress.dart b/example/lib/src/screens/main/map_view/components/download_progress/download_progress.dart new file mode 100644 index 00000000..6430fd68 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/download_progress/download_progress.dart @@ -0,0 +1,193 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +class DownloadProgressCover extends SingleChildRenderObjectWidget { + const DownloadProgressCover({ + super.key, + super.child, + required this.mapCamera, + }); + + final MapCamera mapCamera; + + @override + RenderObject createRenderObject(BuildContext context) => + _DownloadProgressCoverMask(mapCamera: mapCamera); + + @override + void updateRenderObject( + BuildContext context, + // ignore: library_private_types_in_public_api + _DownloadProgressCoverMask renderObject, + ) { + renderObject.mapCamera = mapCamera; + } +} + +class _DownloadProgressCoverMask extends RenderProxyBox { + _DownloadProgressCoverMask({ + required MapCamera mapCamera, + }) : _mapCamera = mapCamera; + + MapCamera _mapCamera; + MapCamera get mapCamera => _mapCamera; + set mapCamera(MapCamera value) { + if (value == mapCamera) return; + _mapCamera = value; + markNeedsPaint(); + } + + static ColorFilter _grayscale(double percentage) { + final amount = 1 - percentage; + return ColorFilter.matrix([ + (0.2126 + 0.7874 * amount), + (0.7152 - 0.7152 * amount), + (0.0722 - 0.0722 * amount), + 0, + 0, + (0.2126 - 0.2126 * amount), + (0.7152 + 0.2848 * amount), + (0.0722 - 0.0722 * amount), + 0, + 0, + (0.2126 - 0.2126 * amount), + (0.7152 - 0.7152 * amount), + (0.0722 + 0.9278 * amount), + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]); + } + + final targetCoord = const LatLng(45.3271, 14.4422); + final tileSize = 256; + late final targetTiles = List.generate( + 7, + (z) { + final zoom = z + 12; + final (x, y) = mapCamera.crs + .latLngToXY(targetCoord, mapCamera.crs.scale(zoom.toDouble())); + return TileCoordinates( + (x / tileSize).floor(), + (y / tileSize).floor(), + zoom, + ); + }, + ); + + @override + void paint(PaintingContext context, Offset offset) { + final layerHandles = Iterable.generate( + double.maxFinite.toInt(), + (_) => LayerHandle(), + ); + + final rects = targetTiles.map((tile) { + final nw = mapCamera.latLngToScreenPoint( + mapCamera.crs.pointToLatLng(tile * tileSize, tile.z.toDouble()), + ); + final se = mapCamera.latLngToScreenPoint( + mapCamera.crs.pointToLatLng( + (tile + const Point(1, 1)) * tileSize, + tile.z.toDouble(), + ), + ); + return Rect.fromPoints(nw.toOffset(), se.toOffset()); + }); + + context.pushColorFilter( + offset, + _grayscale(1), + (context, offset) => context.paintChild(child!, offset), + + //oldLayer: layerHandles.elementAt(layerHandleIndex).layer, + ); + + // context.paintChild(child!, offset); + + int layerHandleIndex = 0; + for (int i = 0; i < rects.length; i++) { + final rect = rects.elementAt(i); + + /*final oldMin = 0; + final oldMax = rects.length - 1; + final newMin = 0; + final newMax = 1; + final oldRange = (oldMax - oldMin); + final newRange = (newMax - newMin); + final amount = (((i - oldMin) * newRange) / oldRange) + newMin; + print('$i: $amount');*/ + + layerHandles.elementAt(layerHandleIndex).layer = context.pushColorFilter( + offset, + _grayscale(((rects.length - 1) - i) / 7), + (context, offset) => context.pushClipRect( + true, + offset, + rect, + (context, offset) => context.paintChild(child!, offset), + ), + oldLayer: layerHandles.elementAt(layerHandleIndex).layer, + ); + + layerHandleIndex++; + } + + /*const double chessSize = 100; + final rows = size.height ~/ chessSize; + final cols = size.width ~/ chessSize; + + int i = 0; + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + /*_clipLayerHandles[childIndex].layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + (context, offset) => + context.paintChild(child!, offset + Offset(childIndex * 50, 0)), + oldLayer: _clipLayerHandles[childIndex].layer, + );*/ + layerHandles.elementAt(i).layer = context.pushColorFilter( + offset, + _grayscale(i % 2), + (context, offset) => context.pushClipRect( + true, + offset, + Rect.fromLTWH( + c * chessSize, + r * chessSize, + chessSize, + chessSize, + ), + (context, offset) => context.paintChild(child!, offset), + ), + oldLayer: layerHandles.elementAt(i).layer, + ); + i++; + } + }*/ + + context.canvas.drawCircle(offset, 100, Paint()..color = Colors.blue); + + int rectI = 0; + for (final rect in rects) { + context.canvas.drawRect( + rect, + Paint() + ..style = PaintingStyle.stroke + ..color = Colors.black + ..strokeWidth = 3, + ); + rectI++; + } + } +} 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 52d5482a..75679154 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -14,6 +14,7 @@ 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/download_progress.dart'; import 'components/region_selection/crosshairs.dart'; import 'components/region_selection/custom_polygon_snapping_indicator.dart'; import 'components/region_selection/region_shape.dart'; @@ -288,34 +289,40 @@ class _MapViewState extends State with TickerProviderStateMixin { mapController: _mapController.mapController, options: mapOptions, children: [ - TileLayer( - urlTemplate: urlTemplate, - userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', - maxNativeZoom: 20, - tileProvider: compiledStoreNames.isEmpty && - otherStoresStrategy == null - ? NetworkTileProvider() - : FMTCTileProvider.multipleStores( - storeNames: compiledStoreNames, - otherStoresStrategy: otherStoresStrategy, - loadingStrategy: provider.loadingStrategy, - useOtherStoresAsFallbackOnly: - provider.useUnspecifiedAsFallbackOnly, - recordHitsAndMisses: false, - tileLoadingInterceptor: _tileLoadingDebugger, - httpClient: _httpClient, - // ignore: invalid_use_of_visible_for_testing_member - fakeNetworkDisconnect: provider.fakeNetworkDisconnect, - ), - tileBuilder: !provider.displayDebugOverlay - ? null - : (context, tileWidget, tile) => DebuggingTileBuilder( - tileLoadingDebugger: _tileLoadingDebugger, - tileWidget: tileWidget, - tile: tile, - usingFMTC: compiledStoreNames.isNotEmpty || - otherStoresStrategy != null, - ), + Builder( + builder: (context) => DownloadProgressCover( + mapCamera: MapCamera.of(context), + child: TileLayer( + urlTemplate: urlTemplate, + userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', + maxNativeZoom: 20, + tileProvider: compiledStoreNames.isEmpty && + otherStoresStrategy == null + ? NetworkTileProvider() + : FMTCTileProvider.multipleStores( + storeNames: compiledStoreNames, + otherStoresStrategy: otherStoresStrategy, + loadingStrategy: provider.loadingStrategy, + useOtherStoresAsFallbackOnly: + provider.useUnspecifiedAsFallbackOnly, + recordHitsAndMisses: false, + tileLoadingInterceptor: _tileLoadingDebugger, + httpClient: _httpClient, + // ignore: invalid_use_of_visible_for_testing_member + fakeNetworkDisconnect: + provider.fakeNetworkDisconnect, + ), + tileBuilder: !provider.displayDebugOverlay + ? null + : (context, tileWidget, tile) => DebuggingTileBuilder( + tileLoadingDebugger: _tileLoadingDebugger, + tileWidget: tileWidget, + tile: tile, + usingFMTC: compiledStoreNames.isNotEmpty || + otherStoresStrategy != null, + ), + ), + ), ), if (widget.mode == MapViewMode.downloadRegion) ...[ const RegionShape(), From 40acefed8e65c62296586eedf2af0ce9773cdbb9 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 3 Oct 2024 17:14:51 +0100 Subject: [PATCH 59/97] Example app experimentation --- example/devtools_options.yaml | 3 + .../components/greyscale_masker.dart | 395 ++++++++++++++++++ .../download_progress/download_progress.dart | 193 --------- .../download_progress_masker.dart | 28 ++ .../src/screens/main/map_view/map_view.dart | 115 +++-- 5 files changed, 506 insertions(+), 228 deletions(-) create mode 100644 example/devtools_options.yaml create mode 100644 example/lib/src/screens/main/map_view/components/download_progress/components/greyscale_masker.dart delete mode 100644 example/lib/src/screens/main/map_view/components/download_progress/download_progress.dart create mode 100644 example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart diff --git a/example/devtools_options.yaml b/example/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/example/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: 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 new file mode 100644 index 00000000..70c5a0fa --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/download_progress/components/greyscale_masker.dart @@ -0,0 +1,395 @@ +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'; + +class GreyscaleMasker extends SingleChildRenderObjectWidget { + const GreyscaleMasker({ + super.key, + super.child, + required this.mapCamera, + required this.tileCoordinates, + required this.minZoom, + required this.maxZoom, + required this.tileSize, + }); + + final MapCamera mapCamera; + final Stream tileCoordinates; + final int minZoom; + final int maxZoom; + final int tileSize; + + @override + RenderObject createRenderObject(BuildContext context) => + _GreyscaleMaskerRenderer( + mapCamera: mapCamera, + tileCoordinates: tileCoordinates, + minZoom: minZoom, + maxZoom: maxZoom, + tileSize: tileSize, + ); + + @override + void updateRenderObject( + BuildContext context, + // ignore: library_private_types_in_public_api + _GreyscaleMaskerRenderer renderObject, + ) { + renderObject.mapCamera = mapCamera; + } +} + +class _GreyscaleMaskerRenderer extends RenderProxyBox { + _GreyscaleMaskerRenderer({ + required MapCamera mapCamera, + required Stream tileCoordinates, + required this.minZoom, + required this.maxZoom, + required this.tileSize, + }) : assert( + maxZoom - minZoom < 32, + 'Unable to store large numbers that result from handling `maxZoom` ' + '- `minZoom`', + ), + _mapCamera = mapCamera { + // Precalculate for more efficient percentage calculations later + _possibleSubtilesCountPerZoomLevel = Uint64List((maxZoom - minZoom) + 1); + int p = 0; + for (int i = minZoom; i < maxZoom; i++) { + _possibleSubtilesCountPerZoomLevel[p] = pow(4, maxZoom - i).toInt(); + p++; + } + _possibleSubtilesCountPerZoomLevel[p] = 0; + + // Handle incoming tile coordinates + tileCoordinates.listen(_incomingTileHandler); + } + + MapCamera _mapCamera; + MapCamera get mapCamera => _mapCamera; + set mapCamera(MapCamera value) { + if (value == mapCamera) return; + _mapCamera = value; + markNeedsPaint(); + } + + final int minZoom; + final int maxZoom; + final int tileSize; + + late final StreamSubscription _tileCoordinatesSub; + + /// Maps tiles of a download to the number of subtiles downloaded + /// + /// Due to the multi-threaded nature of downloading, it is important to note + /// when modifying this map that the parentist tile may not yet be + /// registered in the map if it has been queued for another thread. In this + /// case, the value should be initialised to 0, then the thread which + /// eventually downloads the parentist tile should increment the value. With + /// the exception of this case, the existence of a tile key is an indication + /// that that parent tile has been downloaded. + /// + /// TODO: Use minZoom system and another 'temp' mapping to prevent the issue + /// above by treating as minZoom until minnerZoom. + /// + /// The map assigned must be immutable: it must be reconstructed for every + /// update. + final Map _tileMapping = SplayTreeMap( + (a, b) => a.z.compareTo(b.z) | a.x.compareTo(b.x) | a.y.compareTo(b.y), + ); + + //final Set _tempTileStorage = {}; + + /// The number of subtiles a tile at the zoom level (index) may have + late final Uint64List _possibleSubtilesCountPerZoomLevel; + + static ColorFilter _grayscale(double percentage) { + final amount = 1 - percentage; + return ColorFilter.matrix([ + (0.2126 + 0.7874 * amount), + (0.7152 - 0.7152 * amount), + (0.0722 - 0.0722 * amount), + 0, + 0, + (0.2126 - 0.2126 * amount), + (0.7152 + 0.2848 * amount), + (0.0722 - 0.0722 * amount), + 0, + 0, + (0.2126 - 0.2126 * amount), + (0.7152 - 0.7152 * amount), + (0.0722 + 0.9278 * amount), + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]); + } + + /*final targetCoord = const LatLng(45.3271, 14.4422); + late final targetTiles = List.generate( + 7, + (z) { + final zoom = z + 12; + final (x, y) = mapCamera.crs + .latLngToXY(targetCoord, mapCamera.crs.scale(zoom.toDouble())); + return TileCoordinates( + (x / tileSize).floor(), + (y / tileSize).floor(), + zoom, + ); + }, + );*/ + + // Generate fresh layer handles lazily, as many as is needed + // + // Required to allow the child to be painted multiple times. + final _layerHandles = Iterable.generate( + double.maxFinite.toInt(), + (_) => LayerHandle(), + ); + + static TileCoordinates _recurseTileToMinZoomLevelParentWithCallback( + TileCoordinates tile, + int absMinZoom, + void Function(TileCoordinates tile) zoomLevelCallback, + ) { + assert( + tile.z >= absMinZoom, + '`tile.z` must be greater than or equal to `minZoom`', + ); + + zoomLevelCallback(tile); + + if (tile.z == absMinZoom) return tile; + + return _recurseTileToMinZoomLevelParentWithCallback( + TileCoordinates(tile.x ~/ 2, tile.y ~/ 2, tile.z - 1), + absMinZoom, + zoomLevelCallback, + ); + } + + void _incomingTileHandler(TileCoordinates tile) { + assert(tile.z >= minZoom, 'Incoming `tile` has zoom level below minimum'); + assert(tile.z <= maxZoom, 'Incoming `tile` has zoom level above maximum'); + + //print(tile); + + _recurseTileToMinZoomLevelParentWithCallback( + tile, + minZoom, + (intermediateZoomTile) { + final maxSubtilesCount = _possibleSubtilesCountPerZoomLevel[ + intermediateZoomTile.z - minZoom]; + //print('${intermediateZoomTile.z}: $maxSubtilesCount'); + + if (_tileMapping[intermediateZoomTile] case final existingValue?) { + /*assert( + existingValue < maxSubtilesCount, + 'Existing subtiles count cannot be larger than theoretical max ' + 'subtiles count ($intermediateZoomTile: $existingValue >= ' + '$maxSubtilesCount)', + );*/ + + /*if (existingValue + 1 == maxSubtilesCount && + _tileMapping[TileCoordinates( + tile.x ~/ 2, + tile.y ~/ 2, + tile.z - 1, + )] == + _possibleSubtilesCountPerZoomLevel[tile.z - 1 - minZoom] - + 1) { + _tileMapping.remove(intermediateZoomTile); + debugPrint( + 'Removing $intermediateZoomTile, reached max subtiles count of ' + '$maxSubtilesCount & parent contains max tiles', + ); + } else {*/ + _tileMapping[intermediateZoomTile] = existingValue + 1; + //} + } else { + /*if (maxSubtilesCount == 0 && + _tileMapping[TileCoordinates( + tile.x ~/ 2, + tile.y ~/ 2, + tile.z - 1, + )] == + _possibleSubtilesCountPerZoomLevel[tile.z - 1 - minZoom] - + 1) { + debugPrint('Not making new key $intermediateZoomTile'); + } else {*/ + _tileMapping[intermediateZoomTile] = 0; + //debugPrint('Making new key $intermediateZoomTile'); + //} + } + + /*if (_tileMapping[intermediateZoomTile] case final existingValue?) { + _tileMapping[intermediateZoomTile] = existingValue + 1; + } else { + _tempTileStorage.add(intermediateZoomTile); + assert( + _tempTileStorage.length < 50, + 'CAUTION! Temp buffer too full. Likely a bug, or too many threads & small region.', + ); + }*/ + }, + ); + + /*final (int, int) parentistTile; + + if (tile.z == minZoom) { + parentistTile = (tile.x, tile.y); + } else { + final parentistTileWithZoom = + _recurseTileToMinZoomLevelParent(tile, minZoom); + parentistTile = (parentistTileWithZoom.x, parentistTileWithZoom.y); + } + + + _tileMapping[parentistTile] = (_tileMapping[parentistTile] ?? -1) + 1;*/ + + //print(_tileMapping); + + markNeedsPaint(); + } + + @override + void dispose() { + _tileCoordinatesSub.cancel(); + super.dispose(); + } + + @override + void paint(PaintingContext context, Offset offset) { + /*final rects = targetTiles.map((tile) { + final nw = mapCamera.latLngToScreenPoint( + mapCamera.crs.pointToLatLng(tile * tileSize, tile.z.toDouble()), + ); + final se = mapCamera.latLngToScreenPoint( + mapCamera.crs.pointToLatLng( + (tile + const Point(1, 1)) * tileSize, + tile.z.toDouble(), + ), + ); + return Rect.fromPoints(nw.toOffset(), se.toOffset()); + });*/ + + context.pushColorFilter( + offset, + _grayscale(1), + (context, offset) => context.paintChild(child!, offset), + ); + + int layerHandleIndex = 0; + for (int i = 0; i < _tileMapping.length; i++) { + final MapEntry(key: tile, value: subtilesCount) = + _tileMapping.entries.elementAt(i); + + final nw = mapCamera.latLngToScreenPoint( + mapCamera.crs.pointToLatLng(tile * tileSize, tile.z.toDouble()), + ); + final se = mapCamera.latLngToScreenPoint( + mapCamera.crs.pointToLatLng( + (tile + const Point(1, 1)) * tileSize, + tile.z.toDouble(), + ), + ); + final rect = Rect.fromPoints(nw.toOffset(), se.toOffset()); + + /*context.canvas.drawRect( + rect, + Paint() + ..style = PaintingStyle.stroke + ..color = Colors.black + ..strokeWidth = 3, + );*/ + + final maxSubtilesCount = + _possibleSubtilesCountPerZoomLevel[tile.z - minZoom]; + + final greyscaleAmount = + maxSubtilesCount == 0 ? 1.0 : (subtilesCount / maxSubtilesCount); + + _layerHandles.elementAt(layerHandleIndex).layer = context.pushColorFilter( + offset, + _grayscale(1 - greyscaleAmount), + (context, offset) => context.pushClipRect( + needsCompositing, + offset, + rect, + (context, offset) { + context.paintChild(child!, offset); + //context.canvas.clipRect(Offset.zero & size); + //context.canvas.drawColor(Colors.red, BlendMode.src); + }, + ), + oldLayer: _layerHandles.elementAt(layerHandleIndex).layer, + ); + + layerHandleIndex++; + + // TODO: Change to delete 100%ed tiles (recurse down towards maxzoom) + // TODO: Combine into paths + // TODO: Cache paths between paints unless mapcamera changed + } + + /*const double chessSize = 100; + final rows = size.height ~/ chessSize; + final cols = size.width ~/ chessSize; + + int i = 0; + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + /*_clipLayerHandles[childIndex].layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + (context, offset) => + context.paintChild(child!, offset + Offset(childIndex * 50, 0)), + oldLayer: _clipLayerHandles[childIndex].layer, + );*/ + layerHandles.elementAt(i).layer = context.pushColorFilter( + offset, + _grayscale(i % 2), + (context, offset) => context.pushClipRect( + true, + offset, + Rect.fromLTWH( + c * chessSize, + r * chessSize, + chessSize, + chessSize, + ), + (context, offset) => context.paintChild(child!, offset), + ), + oldLayer: layerHandles.elementAt(i).layer, + ); + i++; + } + }*/ + + context.canvas.drawCircle(offset, 100, Paint()..color = Colors.blue); + + /*int rectI = 0; + for (final rect in rects) { + context.canvas.drawRect( + rect, + Paint() + ..style = PaintingStyle.stroke + ..color = Colors.black + ..strokeWidth = 3, + ); + rectI++; + }*/ + } +} diff --git a/example/lib/src/screens/main/map_view/components/download_progress/download_progress.dart b/example/lib/src/screens/main/map_view/components/download_progress/download_progress.dart deleted file mode 100644 index 6430fd68..00000000 --- a/example/lib/src/screens/main/map_view/components/download_progress/download_progress.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; - -class DownloadProgressCover extends SingleChildRenderObjectWidget { - const DownloadProgressCover({ - super.key, - super.child, - required this.mapCamera, - }); - - final MapCamera mapCamera; - - @override - RenderObject createRenderObject(BuildContext context) => - _DownloadProgressCoverMask(mapCamera: mapCamera); - - @override - void updateRenderObject( - BuildContext context, - // ignore: library_private_types_in_public_api - _DownloadProgressCoverMask renderObject, - ) { - renderObject.mapCamera = mapCamera; - } -} - -class _DownloadProgressCoverMask extends RenderProxyBox { - _DownloadProgressCoverMask({ - required MapCamera mapCamera, - }) : _mapCamera = mapCamera; - - MapCamera _mapCamera; - MapCamera get mapCamera => _mapCamera; - set mapCamera(MapCamera value) { - if (value == mapCamera) return; - _mapCamera = value; - markNeedsPaint(); - } - - static ColorFilter _grayscale(double percentage) { - final amount = 1 - percentage; - return ColorFilter.matrix([ - (0.2126 + 0.7874 * amount), - (0.7152 - 0.7152 * amount), - (0.0722 - 0.0722 * amount), - 0, - 0, - (0.2126 - 0.2126 * amount), - (0.7152 + 0.2848 * amount), - (0.0722 - 0.0722 * amount), - 0, - 0, - (0.2126 - 0.2126 * amount), - (0.7152 - 0.7152 * amount), - (0.0722 + 0.9278 * amount), - 0, - 0, - 0, - 0, - 0, - 1, - 0, - ]); - } - - final targetCoord = const LatLng(45.3271, 14.4422); - final tileSize = 256; - late final targetTiles = List.generate( - 7, - (z) { - final zoom = z + 12; - final (x, y) = mapCamera.crs - .latLngToXY(targetCoord, mapCamera.crs.scale(zoom.toDouble())); - return TileCoordinates( - (x / tileSize).floor(), - (y / tileSize).floor(), - zoom, - ); - }, - ); - - @override - void paint(PaintingContext context, Offset offset) { - final layerHandles = Iterable.generate( - double.maxFinite.toInt(), - (_) => LayerHandle(), - ); - - final rects = targetTiles.map((tile) { - final nw = mapCamera.latLngToScreenPoint( - mapCamera.crs.pointToLatLng(tile * tileSize, tile.z.toDouble()), - ); - final se = mapCamera.latLngToScreenPoint( - mapCamera.crs.pointToLatLng( - (tile + const Point(1, 1)) * tileSize, - tile.z.toDouble(), - ), - ); - return Rect.fromPoints(nw.toOffset(), se.toOffset()); - }); - - context.pushColorFilter( - offset, - _grayscale(1), - (context, offset) => context.paintChild(child!, offset), - - //oldLayer: layerHandles.elementAt(layerHandleIndex).layer, - ); - - // context.paintChild(child!, offset); - - int layerHandleIndex = 0; - for (int i = 0; i < rects.length; i++) { - final rect = rects.elementAt(i); - - /*final oldMin = 0; - final oldMax = rects.length - 1; - final newMin = 0; - final newMax = 1; - final oldRange = (oldMax - oldMin); - final newRange = (newMax - newMin); - final amount = (((i - oldMin) * newRange) / oldRange) + newMin; - print('$i: $amount');*/ - - layerHandles.elementAt(layerHandleIndex).layer = context.pushColorFilter( - offset, - _grayscale(((rects.length - 1) - i) / 7), - (context, offset) => context.pushClipRect( - true, - offset, - rect, - (context, offset) => context.paintChild(child!, offset), - ), - oldLayer: layerHandles.elementAt(layerHandleIndex).layer, - ); - - layerHandleIndex++; - } - - /*const double chessSize = 100; - final rows = size.height ~/ chessSize; - final cols = size.width ~/ chessSize; - - int i = 0; - for (int r = 0; r < rows; r++) { - for (int c = 0; c < cols; c++) { - /*_clipLayerHandles[childIndex].layer = context.pushClipRect( - needsCompositing, - offset, - Offset.zero & size, - (context, offset) => - context.paintChild(child!, offset + Offset(childIndex * 50, 0)), - oldLayer: _clipLayerHandles[childIndex].layer, - );*/ - layerHandles.elementAt(i).layer = context.pushColorFilter( - offset, - _grayscale(i % 2), - (context, offset) => context.pushClipRect( - true, - offset, - Rect.fromLTWH( - c * chessSize, - r * chessSize, - chessSize, - chessSize, - ), - (context, offset) => context.paintChild(child!, offset), - ), - oldLayer: layerHandles.elementAt(i).layer, - ); - i++; - } - }*/ - - context.canvas.drawCircle(offset, 100, Paint()..color = Colors.blue); - - int rectI = 0; - for (final rect in rects) { - context.canvas.drawRect( - rect, - Paint() - ..style = PaintingStyle.stroke - ..color = Colors.black - ..strokeWidth = 3, - ); - rectI++; - } - } -} 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 new file mode 100644 index 00000000..8eabca1f --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import 'components/greyscale_masker.dart'; + +class DownloadProgressMasker extends StatefulWidget { + const DownloadProgressMasker({ + super.key, + required this.child, + }); + + final TileLayer child; + + @override + State createState() => _DownloadProgressMaskerState(); +} + +class _DownloadProgressMaskerState extends State { + @override + Widget build( + BuildContext + context) => /* GreyscaleMasker( + mapCamera: MapCamera.of(context), + tileMapping: _tileMapping, + child: widget.child, + );*/ + Placeholder(); +} 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 75679154..8c5d0832 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -14,7 +14,8 @@ 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/download_progress.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'; @@ -63,6 +64,8 @@ class _MapViewState extends State with TickerProviderStateMixin { }, ); + Stream? _testingDownloadTileCoordsStream; + bool _isInRegionSelectMode() => widget.mode == MapViewMode.downloadRegion && !context.read().isDownloadSetupPanelVisible; @@ -85,7 +88,37 @@ class _MapViewState extends State with TickerProviderStateMixin { keepAlive: true, backgroundColor: const Color(0xFFaad3df), onTap: (_, __) { - if (!_isInRegionSelectMode()) return; + if (!_isInRegionSelectMode()) { + setState( + () => _testingDownloadTileCoordsStream = + const FMTCStore('Mapbox JaffaKetchup Outdoors') + .download + .startForeground( + region: CircleRegion( + LatLng(45.3052535669648, 14.476223064038985), + 10, + ).toDownloadable( + minZoom: 11, + maxZoom: 18, + options: TileLayer( + urlTemplate: 'http://127.0.0.1:7070/{z}/{x}/{y}', + ), + ), + parallelThreads: 10, + skipSeaTiles: false, + urlTransformer: (url) => + FMTCTileProvider.urlTransformerOmitKeyValues( + url: url, + keys: ['access_token'], + ), + rateLimit: 200, + ) + .map( + (event) => event.latestTileEvent.coordinates, + ), + ); + return; + } final provider = context.read(); @@ -133,6 +166,7 @@ class _MapViewState extends State with TickerProviderStateMixin { context.read().removeLastCoordinate(); }, onLongPress: (_, __) { + const FMTCStore('Mapbox JaffaKetchup Outdoors').download.cancel(); if (!_isInRegionSelectMode()) return; context.read().removeLastCoordinate(); }, @@ -285,45 +319,56 @@ class _MapViewState extends State with TickerProviderStateMixin { final otherStoresStrategy = provider.currentStores['(unspecified)'] ?.toBrowseStoreStrategy(); + final tileLayer = TileLayer( + urlTemplate: urlTemplate, + userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', + maxNativeZoom: 20, + tileProvider: + compiledStoreNames.isEmpty && otherStoresStrategy == null + ? NetworkTileProvider() + : FMTCTileProvider.multipleStores( + storeNames: compiledStoreNames, + otherStoresStrategy: otherStoresStrategy, + loadingStrategy: provider.loadingStrategy, + useOtherStoresAsFallbackOnly: + provider.useUnspecifiedAsFallbackOnly, + recordHitsAndMisses: false, + tileLoadingInterceptor: _tileLoadingDebugger, + httpClient: _httpClient, + // ignore: invalid_use_of_visible_for_testing_member + fakeNetworkDisconnect: provider.fakeNetworkDisconnect, + ), + tileBuilder: !provider.displayDebugOverlay + ? null + : (context, tileWidget, tile) => DebuggingTileBuilder( + tileLoadingDebugger: _tileLoadingDebugger, + tileWidget: tileWidget, + tile: tile, + usingFMTC: compiledStoreNames.isNotEmpty || + otherStoresStrategy != null, + ), + ); + final map = FlutterMap( mapController: _mapController.mapController, options: mapOptions, children: [ - Builder( - builder: (context) => DownloadProgressCover( - mapCamera: MapCamera.of(context), - child: TileLayer( - urlTemplate: urlTemplate, - userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', - maxNativeZoom: 20, - tileProvider: compiledStoreNames.isEmpty && - otherStoresStrategy == null - ? NetworkTileProvider() - : FMTCTileProvider.multipleStores( - storeNames: compiledStoreNames, - otherStoresStrategy: otherStoresStrategy, - loadingStrategy: provider.loadingStrategy, - useOtherStoresAsFallbackOnly: - provider.useUnspecifiedAsFallbackOnly, - recordHitsAndMisses: false, - tileLoadingInterceptor: _tileLoadingDebugger, - httpClient: _httpClient, - // ignore: invalid_use_of_visible_for_testing_member - fakeNetworkDisconnect: - provider.fakeNetworkDisconnect, - ), - tileBuilder: !provider.displayDebugOverlay - ? null - : (context, tileWidget, tile) => DebuggingTileBuilder( - tileLoadingDebugger: _tileLoadingDebugger, - tileWidget: tileWidget, - tile: tile, - usingFMTC: compiledStoreNames.isNotEmpty || - otherStoresStrategy != null, - ), + if (_testingDownloadTileCoordsStream == null) + tileLayer + else + RepaintBoundary( + child: Builder( + builder: (context) => GreyscaleMasker( + key: const ValueKey(11), + mapCamera: MapCamera.of(context), + tileCoordinates: _testingDownloadTileCoordsStream!, + minZoom: 11, + maxZoom: 18, + tileSize: 256, + child: tileLayer, + ), ), ), - ), if (widget.mode == MapViewMode.downloadRegion) ...[ const RegionShape(), const CustomPolygonSnappingIndicator(), From ec2130ea4d37d8d0ca63ba79558d6877bc25a8a1 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 4 Oct 2024 00:02:23 +0100 Subject: [PATCH 60/97] Example app experimentation --- .../components/greyscale_masker.dart | 483 ++++++++++-------- .../src/screens/main/map_view/map_view.dart | 120 ++++- lib/src/bulk_download/internal/manager.dart | 1 + 3 files changed, 376 insertions(+), 228 deletions(-) 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 70c5a0fa..04efd474 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 @@ -6,21 +6,21 @@ 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'; +import 'package:latlong2/latlong.dart' hide Path; class GreyscaleMasker extends SingleChildRenderObjectWidget { const GreyscaleMasker({ super.key, - super.child, + required super.child, + required this.tileCoordinatesStream, required this.mapCamera, - required this.tileCoordinates, required this.minZoom, required this.maxZoom, - required this.tileSize, + this.tileSize = 256, }); + final Stream tileCoordinatesStream; final MapCamera mapCamera; - final Stream tileCoordinates; final int minZoom; final int maxZoom; final int tileSize; @@ -29,7 +29,7 @@ class GreyscaleMasker extends SingleChildRenderObjectWidget { RenderObject createRenderObject(BuildContext context) => _GreyscaleMaskerRenderer( mapCamera: mapCamera, - tileCoordinates: tileCoordinates, + tileCoordinatesStream: tileCoordinatesStream, minZoom: minZoom, maxZoom: maxZoom, tileSize: tileSize, @@ -42,74 +42,116 @@ class GreyscaleMasker extends SingleChildRenderObjectWidget { _GreyscaleMaskerRenderer renderObject, ) { renderObject.mapCamera = mapCamera; + // We don't support changing the other properties. They should not change + // during a download. } } class _GreyscaleMaskerRenderer extends RenderProxyBox { _GreyscaleMaskerRenderer({ + required Stream tileCoordinatesStream, required MapCamera mapCamera, - required Stream tileCoordinates, required this.minZoom, required this.maxZoom, required this.tileSize, }) : assert( maxZoom - minZoom < 32, - 'Unable to store large numbers that result from handling `maxZoom` ' - '- `minZoom`', + 'Unable to work with the large numbers that result from handling the ' + 'difference of `maxZoom` & `minZoom`', ), _mapCamera = mapCamera { - // Precalculate for more efficient percentage calculations later - _possibleSubtilesCountPerZoomLevel = Uint64List((maxZoom - minZoom) + 1); + // Precalculate for more efficient greyscale amount calculations later + _maxSubtilesCountPerZoomLevel = Uint64List((maxZoom - minZoom) + 1); int p = 0; for (int i = minZoom; i < maxZoom; i++) { - _possibleSubtilesCountPerZoomLevel[p] = pow(4, maxZoom - i).toInt(); + _maxSubtilesCountPerZoomLevel[p] = pow(4, maxZoom - i).toInt(); p++; } - _possibleSubtilesCountPerZoomLevel[p] = 0; + _maxSubtilesCountPerZoomLevel[p] = 0; // Handle incoming tile coordinates - tileCoordinates.listen(_incomingTileHandler); + tileCoordinatesStream.listen(_incomingTileHandler); } + //! PROPERTIES + MapCamera _mapCamera; MapCamera get mapCamera => _mapCamera; set mapCamera(MapCamera value) { if (value == mapCamera) return; _mapCamera = value; + _recompileGreyscalePathCache(); markNeedsPaint(); } + /// Minimum zoom level of the download + /// + /// The difference of [maxZoom] & [minZoom] must be less than 32, due to + /// limitations with 64-bit integers. final int minZoom; + + /// Maximum zoom level of the download + /// + /// The difference of [maxZoom] & [minZoom] must be less than 32, due to + /// limitations with 64-bit integers. final int maxZoom; + + /// Size of each tile in pixels final int tileSize; + //! STATE + + /// Stream subscription for input `tileCoordinates` stream late final StreamSubscription _tileCoordinatesSub; - /// Maps tiles of a download to the number of subtiles downloaded + /// Maps tiles of a download to a [_TileMappingValue], which contains: + /// * the number of subtiles downloaded + /// * the lat/lng coordinates of the tile's top-left (North-West) & + /// bottom-left (South-East) corners, which is cached to improve + /// performance when re-projecting to screen space /// /// Due to the multi-threaded nature of downloading, it is important to note - /// when modifying this map that the parentist tile may not yet be - /// registered in the map if it has been queued for another thread. In this - /// case, the value should be initialised to 0, then the thread which - /// eventually downloads the parentist tile should increment the value. With - /// the exception of this case, the existence of a tile key is an indication - /// that that parent tile has been downloaded. - /// - /// TODO: Use minZoom system and another 'temp' mapping to prevent the issue - /// above by treating as minZoom until minnerZoom. - /// - /// The map assigned must be immutable: it must be reconstructed for every - /// update. - final Map _tileMapping = SplayTreeMap( + /// when modifying this map that the root tile may not yet be registered in + /// the map if it has been queued for another thread. In this case, the value + /// should be initialised to 0, then the thread which eventually downloads the + /// root tile should increment the value. With the exception of this case, the + /// existence of a tile key is an indication that that parent tile has been + /// downloaded. + final Map _tileMapping = SplayTreeMap( (a, b) => a.z.compareTo(b.z) | a.x.compareTo(b.x) | a.y.compareTo(b.y), ); - //final Set _tempTileStorage = {}; - /// The number of subtiles a tile at the zoom level (index) may have - late final Uint64List _possibleSubtilesCountPerZoomLevel; + late final Uint64List _maxSubtilesCountPerZoomLevel; + + /// Cache for a greyscale amount to the path that should be painted with that + /// greyscale level + /// + /// The key is multiplied by 1/[_greyscaleLevelsCount] to give the greyscale + /// percentage. This means there are [_greyscaleLevelsCount] levels of + /// greyscale available. Because the difference between close greyscales is + /// very difficult to percieve with the eye, this is acceptable, and improves + /// performance drastically. The ideal amount is calculated and rounded to the + /// nearest level. + static const _greyscaleLevelsCount = 25; + final Map _greyscalePathCache = Map.unmodifiable({ + for (int i = 0; i <= _greyscaleLevelsCount; i++) i: Path(), + }); - static ColorFilter _grayscale(double percentage) { + @override + void dispose() { + _tileCoordinatesSub.cancel(); + super.dispose(); + } + + //! GREYSCALE HANDLING + + /// Calculate the grayscale color filter given a percentage + /// + /// 1 is fully greyscale, 0 is fully original color. + /// + /// From https://www.w3.org/TR/filter-effects-1/#grayscaleEquivalent. + static ColorFilter _generateGreyscaleFilter(double percentage) { final amount = 1 - percentage; return ColorFilter.matrix([ (0.2126 + 0.7874 * amount), @@ -135,29 +177,33 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { ]); } - /*final targetCoord = const LatLng(45.3271, 14.4422); - late final targetTiles = List.generate( - 7, - (z) { - final zoom = z + 12; - final (x, y) = mapCamera.crs - .latLngToXY(targetCoord, mapCamera.crs.scale(zoom.toDouble())); - return TileCoordinates( - (x / tileSize).floor(), - (y / tileSize).floor(), - zoom, - ); - }, - );*/ + /// Calculate the greyscale level given the number of subtiles actually + /// downloaded and the possible number of subtiles + /// + /// Multiply by 1/[_greyscaleLevelsCount] to pass to [_generateGreyscaleFilter] + /// to generate [ColorFilter]. + int _calculateGreyscaleLevel(int subtilesCount, int maxSubtilesCount) { + assert( + subtilesCount <= maxSubtilesCount, + '`subtilesCount` must be less than or equal to `maxSubtilesCount`', + ); - // Generate fresh layer handles lazily, as many as is needed - // - // Required to allow the child to be painted multiple times. - final _layerHandles = Iterable.generate( - double.maxFinite.toInt(), - (_) => LayerHandle(), - ); + final invGreyscalePercentage = + (subtilesCount + 1) / (maxSubtilesCount + 1); // +1 to count self + return _greyscaleLevelsCount - + (invGreyscalePercentage * _greyscaleLevelsCount).round(); + } + //! INPUT STREAM HANDLING + + /// Recursively work towards the root tile at the [absMinZoom] (usually + /// [minZoom]) given a [tile] + /// + /// [zoomLevelCallback] is invoked with the tile at each recursed zoom level, + /// including the original and [absMinZoom] level. + /// + /// In general we recurse towards the root tile because the download occurs + /// from the root tile towards the leaf tiles. static TileCoordinates _recurseTileToMinZoomLevelParentWithCallback( TileCoordinates tile, int absMinZoom, @@ -179,217 +225,220 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { ); } + /// Project specified coordinates to a screen space [Rect] + Rect _calculateRectOfCoords(LatLng nwCoord, LatLng seCoord) { + final nwScreen = mapCamera.latLngToScreenPoint(nwCoord); + final seScreen = mapCamera.latLngToScreenPoint(seCoord); + return Rect.fromPoints(nwScreen.toOffset(), seScreen.toOffset()); + } + + /// Handles incoming tiles from the input stream, modifying the [_tileMapping] + /// and [_greyscalePathCache] as necessary + /// + /// Tiles are pruned from the tile mapping where the parent tile has maxed out + /// the number of subtiles (ie. all this tile's neighbours within the quad of + /// the parent are also downloaded), to save memory space. However, it is + /// not possible to prune the path cache, so this will slowly become + /// out-of-sync and less efficient. See [_recompileGreyscalePathCache] + /// for details. void _incomingTileHandler(TileCoordinates tile) { assert(tile.z >= minZoom, 'Incoming `tile` has zoom level below minimum'); assert(tile.z <= maxZoom, 'Incoming `tile` has zoom level above maximum'); - //print(tile); - _recurseTileToMinZoomLevelParentWithCallback( tile, minZoom, (intermediateZoomTile) { - final maxSubtilesCount = _possibleSubtilesCountPerZoomLevel[ - intermediateZoomTile.z - minZoom]; - //print('${intermediateZoomTile.z}: $maxSubtilesCount'); - - if (_tileMapping[intermediateZoomTile] case final existingValue?) { - /*assert( - existingValue < maxSubtilesCount, - 'Existing subtiles count cannot be larger than theoretical max ' - 'subtiles count ($intermediateZoomTile: $existingValue >= ' - '$maxSubtilesCount)', - );*/ - - /*if (existingValue + 1 == maxSubtilesCount && - _tileMapping[TileCoordinates( - tile.x ~/ 2, - tile.y ~/ 2, - tile.z - 1, - )] == - _possibleSubtilesCountPerZoomLevel[tile.z - 1 - minZoom] - - 1) { - _tileMapping.remove(intermediateZoomTile); - debugPrint( - 'Removing $intermediateZoomTile, reached max subtiles count of ' - '$maxSubtilesCount & parent contains max tiles', + final maxSubtilesCount = + _maxSubtilesCountPerZoomLevel[intermediateZoomTile.z - minZoom]; + + final _TileMappingValue tmv; + if (_tileMapping[intermediateZoomTile] case final existingTMV?) { + try { + assert( + existingTMV.subtilesCount < maxSubtilesCount, + 'Existing subtiles count must be smaller than max subtiles count ' + '($intermediateZoomTile: ${existingTMV.subtilesCount} !< ' + '$maxSubtilesCount)', ); - } else {*/ - _tileMapping[intermediateZoomTile] = existingValue + 1; - //} + } catch (e) { + print(tile); + print(intermediateZoomTile); + rethrow; + } + + existingTMV.subtilesCount += 1; + tmv = existingTMV; } else { - /*if (maxSubtilesCount == 0 && - _tileMapping[TileCoordinates( - tile.x ~/ 2, - tile.y ~/ 2, - tile.z - 1, - )] == - _possibleSubtilesCountPerZoomLevel[tile.z - 1 - minZoom] - - 1) { - debugPrint('Not making new key $intermediateZoomTile'); - } else {*/ - _tileMapping[intermediateZoomTile] = 0; - //debugPrint('Making new key $intermediateZoomTile'); - //} + final zoom = tile.z.toDouble(); + _tileMapping[intermediateZoomTile] = tmv = _TileMappingValue.newTile( + nwCoord: mapCamera.crs.pointToLatLng(tile * tileSize, zoom), + seCoord: mapCamera.crs + .pointToLatLng((tile + const Point(1, 1)) * tileSize, zoom), + ); } - /*if (_tileMapping[intermediateZoomTile] case final existingValue?) { - _tileMapping[intermediateZoomTile] = existingValue + 1; - } else { - _tempTileStorage.add(intermediateZoomTile); - assert( - _tempTileStorage.length < 50, - 'CAUTION! Temp buffer too full. Likely a bug, or too many threads & small region.', - ); - }*/ + _greyscalePathCache[ + _calculateGreyscaleLevel(tmv.subtilesCount, maxSubtilesCount)]! + .addRect(_calculateRectOfCoords(tmv.nwCoord, tmv.seCoord)); + + late final isParentMaxedOut = _tileMapping[TileCoordinates( + intermediateZoomTile.x ~/ 2, + intermediateZoomTile.y ~/ 2, + intermediateZoomTile.z - 1, + )] + ?.subtilesCount == + _maxSubtilesCountPerZoomLevel[ + intermediateZoomTile.z - 1 - minZoom] - + 1; + if (intermediateZoomTile.z != minZoom && isParentMaxedOut) { + _tileMapping.remove(intermediateZoomTile); // self + + if (intermediateZoomTile.x.isOdd) { + _tileMapping.remove( + TileCoordinates( + intermediateZoomTile.x - 1, + intermediateZoomTile.y, + intermediateZoomTile.z, + ), + ); + } + if (intermediateZoomTile.y.isOdd) { + _tileMapping.remove( + TileCoordinates( + intermediateZoomTile.x, + intermediateZoomTile.y - 1, + intermediateZoomTile.z, + ), + ); + } + if (intermediateZoomTile.x.isOdd && intermediateZoomTile.y.isOdd) { + _tileMapping.remove( + TileCoordinates( + intermediateZoomTile.x - 1, + intermediateZoomTile.y - 1, + intermediateZoomTile.z, + ), + ); + } + } }, ); - /*final (int, int) parentistTile; + markNeedsPaint(); + } - if (tile.z == minZoom) { - parentistTile = (tile.x, tile.y); - } else { - final parentistTileWithZoom = - _recurseTileToMinZoomLevelParent(tile, minZoom); - parentistTile = (parentistTileWithZoom.x, parentistTileWithZoom.y); + /// Recompile the [_greyscalePathCache] ready for repainting based on the + /// single source-of-truth of the [_tileMapping] + /// + /// --- + /// + /// To avoid mutating the cache directly, for performance, we simply reset + /// all paths, which has the same effect but with less work. + /// + /// Then, for every tile, we calculate its greyscale level using its subtiles + /// count and the maximum number of subtiles in its zoom level, and add to + /// that level's `Path` the new rect. + /// + /// The lat/lng coords for the tile are cached and so do not need to be + /// recalculated. They only need to be reprojected to screen space to handle + /// changes to the map camera. This is more performant. + /// + /// We do not ever need to recurse towards the maximum zoom level. We go in + /// order from highest to lowest zoom level when painting, and if a tile at + /// the highest zoom level is fully downloaded (maxed subtiles), then all + /// subtiles will be 0% greyscale anyway, when this tile is painted at 0% + /// greyscale, so we can save unnecessary painting steps. + /// + /// Therefore, it is likely more efficient to paint after running this method + /// than after a series of incoming tiles have been handled (as + /// [_incomingTileHandler] cannot prune the path cache, only the tile mapping). + /// + /// This method does not call [markNeedsPaint], the caller should perform that + /// if necessary. + void _recompileGreyscalePathCache() { + for (final path in _greyscalePathCache.values) { + path.reset(); } + for (int i = _tileMapping.length - 1; i >= 0; i--) { + final MapEntry(key: tile, value: tmv) = _tileMapping.entries.elementAt(i); - _tileMapping[parentistTile] = (_tileMapping[parentistTile] ?? -1) + 1;*/ - - //print(_tileMapping); - - markNeedsPaint(); + _greyscalePathCache[_calculateGreyscaleLevel( + tmv.subtilesCount, + _maxSubtilesCountPerZoomLevel[tile.z - minZoom], + )]! + .addRect(_calculateRectOfCoords(tmv.nwCoord, tmv.seCoord)); + } } - @override - void dispose() { - _tileCoordinatesSub.cancel(); - super.dispose(); - } + //! PAINTING + + /// Generate fresh layer handles lazily, as many as is needed + /// + /// Required to allow the child to be painted multiple times. + final _layerHandles = Iterable.generate( + double.maxFinite.toInt(), + (_) => LayerHandle(), + ); @override void paint(PaintingContext context, Offset offset) { - /*final rects = targetTiles.map((tile) { - final nw = mapCamera.latLngToScreenPoint( - mapCamera.crs.pointToLatLng(tile * tileSize, tile.z.toDouble()), - ); - final se = mapCamera.latLngToScreenPoint( - mapCamera.crs.pointToLatLng( - (tile + const Point(1, 1)) * tileSize, - tile.z.toDouble(), - ), - ); - return Rect.fromPoints(nw.toOffset(), se.toOffset()); - });*/ - + // Paint the map in greyscale context.pushColorFilter( offset, - _grayscale(1), + _generateGreyscaleFilter(1), (context, offset) => context.paintChild(child!, offset), ); + // Then paint, from colorest to greyscalist (high to low zoom level), each + // layer using the respective `Path` as a clip ('cut') int layerHandleIndex = 0; - for (int i = 0; i < _tileMapping.length; i++) { - final MapEntry(key: tile, value: subtilesCount) = - _tileMapping.entries.elementAt(i); - - final nw = mapCamera.latLngToScreenPoint( - mapCamera.crs.pointToLatLng(tile * tileSize, tile.z.toDouble()), - ); - final se = mapCamera.latLngToScreenPoint( - mapCamera.crs.pointToLatLng( - (tile + const Point(1, 1)) * tileSize, - tile.z.toDouble(), - ), - ); - final rect = Rect.fromPoints(nw.toOffset(), se.toOffset()); + for (int i = _greyscalePathCache.length - 1; i >= 0; i--) { + final MapEntry(key: greyscaleAmount, value: path) = + _greyscalePathCache.entries.elementAt(i); - /*context.canvas.drawRect( - rect, - Paint() - ..style = PaintingStyle.stroke - ..color = Colors.black - ..strokeWidth = 3, - );*/ - - final maxSubtilesCount = - _possibleSubtilesCountPerZoomLevel[tile.z - minZoom]; - - final greyscaleAmount = - maxSubtilesCount == 0 ? 1.0 : (subtilesCount / maxSubtilesCount); + final greyscalePercentage = greyscaleAmount * 1 / 25; _layerHandles.elementAt(layerHandleIndex).layer = context.pushColorFilter( offset, - _grayscale(1 - greyscaleAmount), - (context, offset) => context.pushClipRect( + _generateGreyscaleFilter(greyscalePercentage), + (context, offset) => context.pushClipPath( needsCompositing, offset, - rect, + Offset.zero & size, + path, (context, offset) { context.paintChild(child!, offset); - //context.canvas.clipRect(Offset.zero & size); - //context.canvas.drawColor(Colors.red, BlendMode.src); + /*context.canvas.clipRect(Offset.zero & size); + context.canvas.drawColor( + Colors.green, + BlendMode.modulate, + );*/ }, + clipBehavior: Clip.hardEdge, ), oldLayer: _layerHandles.elementAt(layerHandleIndex).layer, ); layerHandleIndex++; - - // TODO: Change to delete 100%ed tiles (recurse down towards maxzoom) - // TODO: Combine into paths - // TODO: Cache paths between paints unless mapcamera changed } - /*const double chessSize = 100; - final rows = size.height ~/ chessSize; - final cols = size.width ~/ chessSize; + context.canvas.drawCircle(offset, 100, Paint()..color = Colors.blue); + } +} - int i = 0; - for (int r = 0; r < rows; r++) { - for (int c = 0; c < cols; c++) { - /*_clipLayerHandles[childIndex].layer = context.pushClipRect( - needsCompositing, - offset, - Offset.zero & size, - (context, offset) => - context.paintChild(child!, offset + Offset(childIndex * 50, 0)), - oldLayer: _clipLayerHandles[childIndex].layer, - );*/ - layerHandles.elementAt(i).layer = context.pushColorFilter( - offset, - _grayscale(i % 2), - (context, offset) => context.pushClipRect( - true, - offset, - Rect.fromLTWH( - c * chessSize, - r * chessSize, - chessSize, - chessSize, - ), - (context, offset) => context.paintChild(child!, offset), - ), - oldLayer: layerHandles.elementAt(i).layer, - ); - i++; - } - }*/ +/// See [_GreyscaleMaskerRenderer._tileMapping] for documentation +/// +/// Is immutable to improve performance. +class _TileMappingValue { + _TileMappingValue.newTile({ + required this.nwCoord, + required this.seCoord, + }) : subtilesCount = 0; - context.canvas.drawCircle(offset, 100, Paint()..color = Colors.blue); + int subtilesCount; - /*int rectI = 0; - for (final rect in rects) { - context.canvas.drawRect( - rect, - Paint() - ..style = PaintingStyle.stroke - ..color = Colors.black - ..strokeWidth = 3, - ); - rectI++; - }*/ - } + final LatLng nwCoord; + final LatLng seCoord; } 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 8c5d0832..61d500d9 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -8,6 +8,7 @@ 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'; @@ -15,7 +16,6 @@ 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'; @@ -64,6 +64,63 @@ class _MapViewState extends State with TickerProviderStateMixin { }, ); + final _testingCoordsList = [ + //TileCoordinates(2212, 1468, 12), + //TileCoordinates(2212 * 2, 1468 * 2, 13), + //TileCoordinates(2212 * 2 * 2, 1468 * 2 * 2, 14), + //TileCoordinates(2212 * 2 * 2 * 2, 1468 * 2 * 2 * 2, 15), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2, + 1468 * 2 * 2 * 2 * 2, + 16, + ), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2 * 2, + 1468 * 2 * 2 * 2 * 2 * 2, + 17, + ), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2 * 2 * 2, + 1468 * 2 * 2 * 2 * 2 * 2 * 2, + 18, + ), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2 * 2 * 2 + 1, + 1468 * 2 * 2 * 2 * 2 * 2 * 2 + 1, + 18, + ), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2 * 2 * 2, + 1468 * 2 * 2 * 2 * 2 * 2 * 2 + 1, + 18, + ), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2, + 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2, + 19, + ), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 1, + 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2, + 19, + ), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2, + 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 1, + 19, + ), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 1, + 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 1, + 19, + ), + const TileCoordinates( + 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 2, + 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 2, + 19, + ), + ]; + Stream? _testingDownloadTileCoordsStream; bool _isInRegionSelectMode() => @@ -91,27 +148,29 @@ class _MapViewState extends State with TickerProviderStateMixin { if (!_isInRegionSelectMode()) { setState( () => _testingDownloadTileCoordsStream = - const FMTCStore('Mapbox JaffaKetchup Outdoors') + const FMTCStore('Local Tile Server') .download .startForeground( - region: CircleRegion( + region: const CircleRegion( LatLng(45.3052535669648, 14.476223064038985), - 10, + 5, ).toDownloadable( minZoom: 11, - maxZoom: 18, + maxZoom: 16, options: TileLayer( - urlTemplate: 'http://127.0.0.1:7070/{z}/{x}/{y}', + //urlTemplate: 'http://127.0.0.1:7070/{z}/{x}/{y}', + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', ), ), - parallelThreads: 10, + parallelThreads: 3, skipSeaTiles: false, urlTransformer: (url) => FMTCTileProvider.urlTransformerOmitKeyValues( url: url, keys: ['access_token'], ), - rateLimit: 200, + rateLimit: 20, ) .map( (event) => event.latestTileEvent.coordinates, @@ -162,11 +221,11 @@ class _MapViewState extends State with TickerProviderStateMixin { } }, onSecondaryTap: (_, __) { + const FMTCStore('Local Tile Server').download.cancel(); if (!_isInRegionSelectMode()) return; context.read().removeLastCoordinate(); }, onLongPress: (_, __) { - const FMTCStore('Mapbox JaffaKetchup Outdoors').download.cancel(); if (!_isInRegionSelectMode()) return; context.read().removeLastCoordinate(); }, @@ -361,14 +420,53 @@ class _MapViewState extends State with TickerProviderStateMixin { builder: (context) => GreyscaleMasker( key: const ValueKey(11), mapCamera: MapCamera.of(context), - tileCoordinates: _testingDownloadTileCoordsStream!, + tileCoordinatesStream: + _testingDownloadTileCoordsStream!, + /*tileCoordinates: Stream.periodic( + const Duration(seconds: 5), + _testingCoordsList.elementAtOrNull, + ).whereNotNull(),*/ minZoom: 11, - maxZoom: 18, + maxZoom: 16, tileSize: 256, child: tileLayer, ), ), ), + PolygonLayer( + polygons: [ + Polygon( + points: [ + LatLng(-90, 180), + LatLng(90, 180), + LatLng(90, -180), + LatLng(-90, -180), + ], + holePointsList: [ + const CircleRegion( + LatLng(45.3052535669648, 14.476223064038985), + 6, + ).toOutline().toList(growable: false), + ], + color: Colors.black, + ), + Polygon( + points: [ + LatLng(-90, 180), + LatLng(90, 180), + LatLng(90, -180), + LatLng(-90, -180), + ], + holePointsList: [ + const CircleRegion( + LatLng(45.3052535669648, 14.476223064038985), + 5, + ).toOutline().toList(growable: false), + ], + color: Colors.black.withAlpha(255 ~/ 2), + ), + ], + ), if (widget.mode == MapViewMode.downloadRegion) ...[ const RegionShape(), const CustomPolygonSnappingIndicator(), diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index 66246d53..ac082e73 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -256,6 +256,7 @@ Future _downloadManager( .then((sp) => sp.send(null)), ); + // TODO: Debug whether multiple threads are downloading the same tile downloadThreadReceivePort.listen( (evt) async { // Thread is sending tile data From a3eae60877af60a1f703109df7f21cd26d26e817 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 9 Oct 2024 10:08:00 +0100 Subject: [PATCH 61/97] Added support for `null` values in `FMTCTileProvider.storeNames` to exempt stores from `otherStoresStrategy` --- .../components/greyscale_masker.dart | 4 +- .../src/screens/main/map_view/map_view.dart | 37 +++++++------------ .../components/start_download_button.dart | 2 +- .../image_provider/internal_get_bytes.dart | 1 + .../tile_provider/tile_provider.dart | 10 +++-- 5 files changed, 23 insertions(+), 31 deletions(-) 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 04efd474..69678117 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 @@ -133,10 +133,10 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /// very difficult to percieve with the eye, this is acceptable, and improves /// performance drastically. The ideal amount is calculated and rounded to the /// nearest level. - static const _greyscaleLevelsCount = 25; final Map _greyscalePathCache = Map.unmodifiable({ for (int i = 0; i <= _greyscaleLevelsCount; i++) i: Path(), }); + static const _greyscaleLevelsCount = 25; @override void dispose() { @@ -430,7 +430,7 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /// See [_GreyscaleMaskerRenderer._tileMapping] for documentation /// -/// Is immutable to improve performance. +/// Is mutable to improve performance. class _TileMappingValue { _TileMappingValue.newTile({ required this.nwCoord, 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 61d500d9..ae4a8d51 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -1,6 +1,9 @@ +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'; import 'package:flutter_map_animations/flutter_map_animations.dart'; @@ -62,7 +65,7 @@ class _MapViewState extends State with TickerProviderStateMixin { .then((e) => e[StoreMetadataKeys.urlTemplate.key]), }; }, - ); + ).distinct(mapEquals); final _testingCoordsList = [ //TileCoordinates(2212, 1468, 12), @@ -336,21 +339,22 @@ class _MapViewState extends State with TickerProviderStateMixin { builder: (context, provider, _) { final urlTemplate = provider.urlTemplate; - final compiledStoreNames = Map.fromEntries( - stores.entries - .where((e) => e.value == urlTemplate) - .map((e) => e.key) - .map((e) { - final internalBehaviour = provider.currentStores[e]; + final compiledStoreNames = + Map.fromEntries([ + ...stores.entries.where((e) => e.value == urlTemplate).map((e) { + final internalBehaviour = provider.currentStores[e.key]; final behaviour = internalBehaviour == null ? provider.inheritableBrowseStoreStrategy : internalBehaviour.toBrowseStoreStrategy( provider.inheritableBrowseStoreStrategy, ); if (behaviour == null) return null; - return MapEntry(e, behaviour); + return MapEntry(e.key, behaviour); }).nonNulls, - ); + ...stores.entries + .where((e) => e.value != urlTemplate) + .map((e) => MapEntry(e.key, null)), + ]); final attribution = RichAttributionWidget( alignment: AttributionAlignment.bottomLeft, @@ -435,21 +439,6 @@ class _MapViewState extends State with TickerProviderStateMixin { ), PolygonLayer( polygons: [ - Polygon( - points: [ - LatLng(-90, 180), - LatLng(90, 180), - LatLng(90, -180), - LatLng(-90, -180), - ], - holePointsList: [ - const CircleRegion( - LatLng(45.3052535669648, 14.476223064038985), - 6, - ).toOutline().toList(growable: false), - ], - color: Colors.black, - ), Polygon( points: [ LatLng(-90, 180), 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 8219ce10..28936fbb 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 @@ -6,8 +6,8 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; import '../../../../shared/misc/store_metadata_keys.dart'; -import '../../download/download.dart'; import '../../../../shared/state/download_configuration_provider.dart'; +import '../../download/download.dart'; class StartDownloadButton extends StatelessWidget { const StartDownloadButton({ diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index 47097393..31a02552 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -207,6 +207,7 @@ Future _internalGetBytes({ final writeTileToSpecified = provider.storeNames.entries .where( (e) => switch (e.value) { + null => false, BrowseStoreStrategy.read => false, BrowseStoreStrategy.readUpdate => intersectedExistingStores.contains(e.key), diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index e6648850..dab0eede 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -80,10 +80,9 @@ class FMTCTileProvider extends TileProvider { /// /// Stores not included will not be used by default. However, /// [otherStoresStrategy] determines whether & how all other unspecified - /// stores should be used. - /// - // TODO: Accept null values to exempt from [otherStoresStrategy] - final Map storeNames; + /// stores should be used. Stores included but with a `null` value will be + /// exempt from [otherStoresStrategy]. + final Map storeNames; /// The behaviour of all other stores not specified in [storeNames] /// @@ -95,6 +94,9 @@ class FMTCTileProvider extends TileProvider { /// Also see [useOtherStoresAsFallbackOnly] for whether these unspecified /// stores should only be used as a last resort or in addition to the specified /// stores as normal. + /// + /// Stores specified in [storeNames] but associated with a `null` value will + /// not not gain this behaviour. final BrowseStoreStrategy? otherStoresStrategy; /// Determines whether the network or cache is preferred during browse From 458a6c39781d4e35db80d826a87cffc3c8e76bda Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 9 Oct 2024 21:37:44 +0100 Subject: [PATCH 62/97] 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([ From eae1ed7b8343735c1b31be9d172139b8d684f5d2 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 14 Oct 2024 16:05:42 +0100 Subject: [PATCH 63/97] Fixed bug in bulk downloader where the final tile event was repeated without `isRepeat` set Undeprecated `DownloadableRegion.(maybe)When` --- example/lib/main.dart | 2 +- .../components/greyscale_masker.dart | 18 ++--- .../download_progress_masker.dart | 19 ++---- .../src/screens/main/map_view/map_view.dart | 22 +++--- .../objectbox/models/src/recovery_region.dart | 14 ++-- .../external/download_progress.dart | 68 ++++++++++--------- .../bulk_download/external/tile_event.dart | 2 +- lib/src/bulk_download/internal/manager.dart | 1 - lib/src/regions/downloadable_region.dart | 31 +++------ 9 files changed, 75 insertions(+), 102 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5dda1505..eadeb96b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -9,10 +9,10 @@ import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/main/main.dart'; import 'src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart'; import 'src/screens/old/configure_download/configure_download.dart'; -import 'src/shared/state/download_configuration_provider.dart'; import 'src/screens/old/download/download.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; +import 'src/shared/state/download_configuration_provider.dart'; import 'src/shared/state/general_provider.dart'; import 'src/shared/state/region_selection_provider.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 1dd2ea07..45416598 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 @@ -246,18 +246,12 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { final _TileMappingValue tmv; if (_tileMapping[intermediateZoomTile] case final existingTMV?) { - try { - assert( - existingTMV.subtilesCount < maxSubtilesCount, - 'Existing subtiles count must be smaller than max subtiles count ' - '($intermediateZoomTile: ${existingTMV.subtilesCount} !< ' - '$maxSubtilesCount)', - ); - } catch (e) { - print(tile); - print(intermediateZoomTile); - rethrow; - } + assert( + existingTMV.subtilesCount < maxSubtilesCount, + 'Existing subtiles count must be smaller than max subtiles count ' + '($intermediateZoomTile: ${existingTMV.subtilesCount} !< ' + '$maxSubtilesCount)', + ); existingTMV.subtilesCount += 1; tmv = existingTMV; 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 7019d91b..a6c456d5 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 @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:latlong2/latlong.dart' hide Path; part 'components/greyscale_masker.dart'; @@ -13,14 +14,14 @@ part 'components/greyscale_masker.dart'; class DownloadProgressMasker extends StatefulWidget { const DownloadProgressMasker({ super.key, - required this.tileCoordinatesStream, + required this.downloadProgressStream, required this.minZoom, required this.maxZoom, this.tileSize = 256, required this.child, }); - final Stream? tileCoordinatesStream; + final Stream? downloadProgressStream; final int minZoom; final int maxZoom; final int tileSize; @@ -33,19 +34,13 @@ class DownloadProgressMasker extends StatefulWidget { class _DownloadProgressMaskerState extends State { @override Widget build(BuildContext context) { - if (widget.tileCoordinatesStream case final tcs?) { + if (widget.downloadProgressStream case final dps?) { return RepaintBoundary( child: GreyscaleMasker( - /*key: ObjectKey( - ( - widget.minZoom, - widget.maxZoom, - widget.tileCoordinatesStream, - widget.tileSize, - ), - ),*/ mapCamera: MapCamera.of(context), - tileCoordinatesStream: tcs, + tileCoordinatesStream: dps + .where((e) => !e.latestTileEvent.isRepeat) + .map((e) => e.latestTileEvent.coordinates), minZoom: widget.minZoom, maxZoom: widget.maxZoom, tileSize: widget.tileSize, 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 9b1dd27a..3122c752 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -122,7 +122,7 @@ class _MapViewState extends State with TickerProviderStateMixin { ), ];*/ - Stream? _testingDownloadTileCoordsStream; + Stream? _testingDownloadTileCoordsStream; bool _isInRegionSelectMode() => widget.mode == MapViewMode.downloadRegion && @@ -149,15 +149,13 @@ class _MapViewState extends State with TickerProviderStateMixin { if (!_isInRegionSelectMode()) { setState( () => _testingDownloadTileCoordsStream = - const FMTCStore('Local Tile Server') - .download - .startForeground( + const FMTCStore('Local Tile Server').download.startForeground( region: const CircleRegion( LatLng(45.3052535669648, 14.476223064038985), 5, ).toDownloadable( - minZoom: 11, - maxZoom: 16, + minZoom: 0, + maxZoom: 12, options: TileLayer( //urlTemplate: 'http://127.0.0.1:7070/{z}/{x}/{y}', urlTemplate: @@ -172,11 +170,7 @@ class _MapViewState extends State with TickerProviderStateMixin { keys: ['access_token'], ), rateLimit: 20, - ) - .map( - (event) => event.latestTileEvent.coordinates, - ) - .asBroadcastStream(), + ), ); return; } @@ -416,13 +410,13 @@ class _MapViewState extends State with TickerProviderStateMixin { options: mapOptions, children: [ DownloadProgressMasker( - tileCoordinatesStream: _testingDownloadTileCoordsStream, + downloadProgressStream: _testingDownloadTileCoordsStream, /*tileCoordinates: Stream.periodic( const Duration(seconds: 5), _testingCoordsList.elementAtOrNull, ).whereNotNull(),*/ - minZoom: 11, - maxZoom: 16, + minZoom: 0, + maxZoom: 12, child: tileLayer, ), PolygonLayer( diff --git a/lib/src/backend/impls/objectbox/models/src/recovery_region.dart b/lib/src/backend/impls/objectbox/models/src/recovery_region.dart index 48b232ee..73443894 100644 --- a/lib/src/backend/impls/objectbox/models/src/recovery_region.dart +++ b/lib/src/backend/impls/objectbox/models/src/recovery_region.dart @@ -34,13 +34,13 @@ class ObjectBoxRecoveryRegion { /// If representing a [MultiRegion], then [multiLinkedRegions] must be filled /// manually. ObjectBoxRecoveryRegion.fromRegion({required BaseRegion region}) - : typeId = region.when( - rectangle: (_) => 0, - circle: (_) => 1, - line: (_) => 2, - customPolygon: (_) => 3, - multi: (_) => 4, - ), + : typeId = switch (region) { + RectangleRegion() => 0, + CircleRegion() => 1, + LineRegion() => 2, + CustomPolygonRegion() => 3, + MultiRegion() => 4, + }, rectNwLat = region is RectangleRegion ? region.bounds.northWest.latitude : null, rectNwLng = region is RectangleRegion diff --git a/lib/src/bulk_download/external/download_progress.dart b/lib/src/bulk_download/external/download_progress.dart index 80eb6d68..54f0e15c 100644 --- a/lib/src/bulk_download/external/download_progress.dart +++ b/lib/src/bulk_download/external/download_progress.dart @@ -201,7 +201,7 @@ class DownloadProgress { required int? rateLimit, }) => DownloadProgress.__( - latestTileEvent: latestTileEvent._repeat(), + latestTileEvent: latestTileEvent._copyWithRepeat(), cachedTiles: cachedTiles, cachedSize: cachedSize, bufferedTiles: bufferedTiles, @@ -225,38 +225,40 @@ class DownloadProgress { required double tilesPerSecond, required int? rateLimit, bool isComplete = false, - }) => - DownloadProgress.__( - latestTileEvent: newTileEvent ?? latestTileEvent, - cachedTiles: newTileEvent != null && - newTileEvent.result.category == TileEventResultCategory.cached - ? cachedTiles + 1 - : cachedTiles, - cachedSize: newTileEvent != null && - newTileEvent.result.category == TileEventResultCategory.cached - ? cachedSize + (newTileEvent.tileImage!.lengthInBytes / 1024) - : cachedSize, - bufferedTiles: newBufferedTiles, - bufferedSize: newBufferedSize, - skippedTiles: newTileEvent != null && - newTileEvent.result.category == TileEventResultCategory.skipped - ? skippedTiles + 1 - : skippedTiles, - skippedSize: newTileEvent != null && - newTileEvent.result.category == TileEventResultCategory.skipped - ? skippedSize + (newTileEvent.tileImage!.lengthInBytes / 1024) - : skippedSize, - failedTiles: newTileEvent != null && - newTileEvent.result.category == TileEventResultCategory.failed - ? failedTiles + 1 - : failedTiles, - maxTiles: maxTiles, - elapsedDuration: newDuration, - tilesPerSecond: tilesPerSecond, - isTPSArtificiallyCapped: - tilesPerSecond >= (rateLimit ?? double.infinity) - 0.5, - isComplete: isComplete, - ); + }) { + final isNewTile = newTileEvent != null; + return DownloadProgress.__( + latestTileEvent: newTileEvent ?? latestTileEvent._copyWithRepeat(), + cachedTiles: isNewTile && + newTileEvent.result.category == TileEventResultCategory.cached + ? cachedTiles + 1 + : cachedTiles, + cachedSize: isNewTile && + newTileEvent.result.category == TileEventResultCategory.cached + ? cachedSize + (newTileEvent.tileImage!.lengthInBytes / 1024) + : cachedSize, + bufferedTiles: newBufferedTiles, + bufferedSize: newBufferedSize, + skippedTiles: isNewTile && + newTileEvent.result.category == TileEventResultCategory.skipped + ? skippedTiles + 1 + : skippedTiles, + skippedSize: isNewTile && + newTileEvent.result.category == TileEventResultCategory.skipped + ? skippedSize + (newTileEvent.tileImage!.lengthInBytes / 1024) + : skippedSize, + failedTiles: isNewTile && + newTileEvent.result.category == TileEventResultCategory.failed + ? failedTiles + 1 + : failedTiles, + maxTiles: maxTiles, + elapsedDuration: newDuration, + tilesPerSecond: tilesPerSecond, + isTPSArtificiallyCapped: + tilesPerSecond >= (rateLimit ?? double.infinity) - 0.5, + isComplete: isComplete, + ); + } @override bool operator ==(Object other) => diff --git a/lib/src/bulk_download/external/tile_event.dart b/lib/src/bulk_download/external/tile_event.dart index 12c5475c..acf26fe0 100644 --- a/lib/src/bulk_download/external/tile_event.dart +++ b/lib/src/bulk_download/external/tile_event.dart @@ -141,7 +141,7 @@ class TileEvent { final bool _wasBufferReset; - TileEvent _repeat() => TileEvent._( + TileEvent _copyWithRepeat() => TileEvent._( result, url: url, coordinates: coordinates, diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index ac082e73..66246d53 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -256,7 +256,6 @@ Future _downloadManager( .then((sp) => sp.send(null)), ); - // TODO: Debug whether multiple threads are downloading the same tile downloadThreadReceivePort.listen( (evt) async { // Thread is sending tile data diff --git a/lib/src/regions/downloadable_region.dart b/lib/src/regions/downloadable_region.dart index 5d9aee9f..ee2bb9ac 100644 --- a/lib/src/regions/downloadable_region.dart +++ b/lib/src/regions/downloadable_region.dart @@ -75,10 +75,6 @@ class DownloadableRegion { /// /// Requires all region types to have a defined handler. See [maybeWhen] for /// the equivalent where this is not required. - @Deprecated( - 'Prefer using a pattern matching selection (such as `if case` or ' - '`switch`). This will be removed in a future version.', - ) T when({ required T Function(DownloadableRegion rectangle) rectangle, @@ -100,10 +96,6 @@ class DownloadableRegion { /// /// If the specified method is not defined for the type of region which this /// region is, `null` will be returned. - @Deprecated( - 'Prefer using a pattern matching selection (such as `if case` or ' - '`switch`). This will be removed in a future version.', - ) T? maybeWhen({ T Function(DownloadableRegion rectangle)? rectangle, T Function(DownloadableRegion circle)? circle, @@ -121,19 +113,16 @@ class DownloadableRegion { }; @override - 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; - } + bool operator ==(Object other) => + identical(this, other) || + (other is DownloadableRegion && + other.originalRegion == originalRegion && + other.minZoom == minZoom && + other.maxZoom == maxZoom && + other.options == options && //! Will never be equal + other.start == start && + other.end == end && + other.crs == crs); @override int get hashCode => Object.hashAllUnordered([ From c5c223ceb05e59a5e50f574cabc9aba52a39aa58 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 15 Oct 2024 22:02:04 +0100 Subject: [PATCH 64/97] Improved example app --- .../components/greyscale_masker.dart | 2 - .../custom_polygon_snapping_indicator.dart | 19 +- .../region_selection/region_shape.dart | 240 +++++++----------- .../src/screens/main/map_view/map_view.dart | 22 +- .../config_options/config_options.dart | 31 ++- 5 files changed, 138 insertions(+), 176 deletions(-) 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 45416598..b714a4c6 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 @@ -409,8 +409,6 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { layerHandleIndex++; } - - context.canvas.drawCircle(offset, 100, Paint()..color = Colors.blue); } } diff --git a/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart b/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart index 52c58cc4..5be14bf2 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart @@ -6,22 +6,20 @@ import 'package:provider/provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; class CustomPolygonSnappingIndicator extends StatelessWidget { - const CustomPolygonSnappingIndicator({ - super.key, - }); + const CustomPolygonSnappingIndicator({super.key}); @override Widget build(BuildContext context) { final coords = context.select>( (p) => p.currentConstructingCoordinates, ); + final customPolygonSnap = context.select( + (p) => p.customPolygonSnap, + ); return MarkerLayer( markers: [ - if (coords.isNotEmpty && - context.select( - (p) => p.customPolygonSnap, - )) + if (coords.isNotEmpty && customPolygonSnap) Marker( height: 32, width: 32, @@ -32,12 +30,7 @@ class CustomPolygonSnappingIndicator extends StatelessWidget { borderRadius: BorderRadius.circular(16), border: Border.all(width: 2), ), - child: const Center( - child: Icon( - Icons.auto_fix_normal, - size: 18, - ), - ), + child: const Center(child: Icon(Icons.auto_fix_normal, size: 18)), ), ), ], diff --git a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart index 88ecb297..1d7d888c 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart @@ -9,153 +9,60 @@ import '../../../../../shared/state/region_selection_provider.dart'; class RegionShape extends StatelessWidget { const RegionShape({super.key}); - static const _fullWorldPolygon = [ - LatLng(-90, 180), - LatLng(90, 180), - LatLng(90, -180), - LatLng(-90, -180), - ]; - @override Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => Stack( - fit: StackFit.expand, - children: [ - for (final MapEntry(key: region, value: color) - in provider.constructedRegions.entries) - switch (region) { - RectangleRegion(:final bounds) => PolygonLayer( - polygons: [ - Polygon( - points: [ - bounds.northWest, - bounds.northEast, - bounds.southEast, - bounds.southWest, - ], - color: color.toColor().withValues(alpha: 0.7), - ), - ], - ), - CircleRegion(:final center, :final radius) => CircleLayer( - circles: [ - CircleMarker( - point: center, - radius: radius * 1000, - useRadiusInMeter: true, - color: color.toColor().withValues(alpha: 0.7), - ), - ], - ), - LineRegion() => PolygonLayer( - polygons: - /* Polyline( - points: line, - strokeWidth: radius * 2, - useStrokeWidthInMeter: true, - color: color.toColor().withValues(alpha: 0.7), - strokeJoin: StrokeJoin.miter, - strokeCap: StrokeCap.square, - ),*/ - region - .toOutlines(1) - .map( - (o) => Polygon( - points: o, - color: color.toColor().withValues(alpha: 0.7), - ), - ) - .toList(growable: false), - ), - CustomPolygonRegion(:final outline) => PolygonLayer( - polygons: [ - Polygon( - points: outline, - color: color.toColor().withValues(alpha: 0.7), - ), - ], - ), - MultiRegion() => throw UnsupportedError( - 'Cannot support `MultiRegion`s here', - ), - }, - if (provider.currentConstructingCoordinates.isNotEmpty) - if (provider.currentRegionType == RegionType.line) - /* PolylineLayer( - polylines: [ - Polyline( - points: [ - ...provider.currentConstructingCoordinates, - provider.currentNewPointPos, + builder: (context, provider, _) { + final ccc = provider.currentConstructingCoordinates; + final cnpp = provider.currentNewPointPos; + + late final renderConstructingRegion = provider.currentRegionType == + RegionType.line + ? LineRegion([...ccc, cnpp], provider.lineRadius) + .toOutlines(1) + .toList(growable: false) + : [ + switch (provider.currentRegionType) { + RegionType.rectangle when ccc.length == 1 => + RectangleRegion(LatLngBounds.fromPoints([ccc[0], cnpp])) + .toOutline() + .toList(), + RegionType.rectangle when ccc.length != 1 => + RectangleRegion(LatLngBounds.fromPoints(ccc)) + .toOutline() + .toList(), + RegionType.circle => CircleRegion( + ccc[0], + const Distance(roundResult: false).distance( + ccc[0], + ccc.length == 1 ? cnpp : ccc[1], + ) / + 1000, + ).toOutline().toList(), + RegionType.customPolygon => [ + ...ccc, + if (provider.customPolygonSnap) ccc.first else cnpp, ], - color: Colors.white.withValues(alpha: 2 / 3), - strokeWidth: provider.lineRadius * 2, - strokeJoin: StrokeJoin.miter, - strokeCap: StrokeCap.square, - useStrokeWidthInMeter: true, - ), - ], - )*/ - PolygonLayer( - polygons: LineRegion( - [ - ...provider.currentConstructingCoordinates, - provider.currentNewPointPos, - ], - provider.lineRadius * 2, - ) - .toOutlines(1) - .map( - (o) => Polygon( - points: o, - color: Colors.white.withValues(alpha: 2 / 3), - ), - ) - .toList(growable: false), - ) - else + _ => throw UnsupportedError('Unreachable.'), + }, + ]; + + return Stack( + fit: StackFit.expand, + children: [ + for (final MapEntry(key: region, value: color) + in provider.constructedRegions.entries) + _renderConstructedRegion(region, color), + if (ccc.isNotEmpty) // Construction in progress PolygonLayer( polygons: [ Polygon( - points: _fullWorldPolygon, - holePointsList: [ - switch (provider.currentRegionType) { - RegionType.circle => CircleRegion( - provider.currentConstructingCoordinates[0], - const Distance(roundResult: false).distance( - provider.currentConstructingCoordinates[0], - provider.currentConstructingCoordinates - .length == - 1 - ? provider.currentNewPointPos - : provider - .currentConstructingCoordinates[1], - ) / - 1000, - ).toOutline().toList(), - RegionType.rectangle => RectangleRegion( - LatLngBounds.fromPoints( - provider.currentConstructingCoordinates - .length == - 1 - ? [ - provider - .currentConstructingCoordinates[0], - provider.currentNewPointPos, - ] - : provider.currentConstructingCoordinates, - ), - ).toOutline().toList(), - RegionType.customPolygon => [ - ...provider.currentConstructingCoordinates, - if (provider.customPolygonSnap) - provider.currentConstructingCoordinates.first - else - provider.currentNewPointPos, - ], - _ => throw UnsupportedError('Unreachable.'), - }, + points: const [ + LatLng(-90, 180), + LatLng(90, 180), + LatLng(90, -180), + LatLng(-90, -180), ], + holePointsList: renderConstructingRegion, borderColor: Colors.black, borderStrokeWidth: 2, color: Theme.of(context) @@ -165,7 +72,56 @@ class RegionShape extends StatelessWidget { ), ], ), - ], - ), + ], + ); + }, ); + + Widget _renderConstructedRegion(BaseRegion region, HSLColor color) => + switch (region) { + RectangleRegion(:final bounds) => PolygonLayer( + polygons: [ + Polygon( + points: [ + bounds.northWest, + bounds.northEast, + bounds.southEast, + bounds.southWest, + ], + color: color.toColor().withValues(alpha: 0.7), + ), + ], + ), + CircleRegion(:final center, :final radius) => CircleLayer( + circles: [ + CircleMarker( + point: center, + radius: radius * 1000, + useRadiusInMeter: true, + color: color.toColor().withValues(alpha: 0.7), + ), + ], + ), + LineRegion() => PolygonLayer( + polygons: region + .toOutlines(1) + .map( + (o) => Polygon( + points: o, + color: color.toColor().withValues(alpha: 0.7), + ), + ) + .toList(growable: false), + ), + CustomPolygonRegion(:final outline) => PolygonLayer( + polygons: [ + Polygon( + points: outline, + color: color.toColor().withValues(alpha: 0.7), + ), + ], + ), + MultiRegion() => + throw UnsupportedError('Cannot support `MultiRegion`s here'), + }; } 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 3122c752..60feb9ea 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -154,12 +154,12 @@ class _MapViewState extends State with TickerProviderStateMixin { LatLng(45.3052535669648, 14.476223064038985), 5, ).toDownloadable( - minZoom: 0, - maxZoom: 12, + minZoom: 5, + maxZoom: 15, options: TileLayer( - //urlTemplate: 'http://127.0.0.1:7070/{z}/{x}/{y}', - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + urlTemplate: 'http://0.0.0.0:7070/{z}/{x}/{y}', + //urlTemplate: + // 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', ), ), parallelThreads: 3, @@ -411,15 +411,11 @@ class _MapViewState extends State with TickerProviderStateMixin { children: [ DownloadProgressMasker( downloadProgressStream: _testingDownloadTileCoordsStream, - /*tileCoordinates: Stream.periodic( - const Duration(seconds: 5), - _testingCoordsList.elementAtOrNull, - ).whereNotNull(),*/ - minZoom: 0, - maxZoom: 12, + minZoom: 5, + maxZoom: 15, child: tileLayer, ), - PolygonLayer( + /*PolygonLayer( polygons: [ Polygon( points: [ @@ -437,7 +433,7 @@ class _MapViewState extends State with TickerProviderStateMixin { color: Colors.black.withAlpha(255 ~/ 2), ), ], - ), + ),*/ if (widget.mode == MapViewMode.downloadRegion) ...[ const RegionShape(), const CustomPolygonSnappingIndicator(), 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 bca5385b..a731cab3 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 @@ -45,7 +45,6 @@ class _ConfigOptionsState extends State { Expanded( child: RangeSlider( values: RangeValues(minZoom.toDouble(), maxZoom.toDouble()), - labels: RangeLabels(minZoom.toString(), maxZoom.toString()), max: 20, divisions: 20, onChanged: (r) => @@ -54,6 +53,10 @@ class _ConfigOptionsState extends State { ..maxZoom = r.end.toInt(), ), ), + Text( + '${minZoom.toString().padLeft(2, '0')} - ' + '${maxZoom.toString().padLeft(2, '0')}', + ), ], ), const Divider(height: 24), @@ -67,7 +70,6 @@ class _ConfigOptionsState extends State { Expanded( child: Slider( value: parallelThreads.toDouble(), - label: '$parallelThreads threads', min: 1, max: 10, divisions: 9, @@ -76,6 +78,13 @@ class _ConfigOptionsState extends State { .parallelThreads = r.toInt(), ), ), + SizedBox( + width: 71, + child: Text( + '$parallelThreads threads', + textAlign: TextAlign.end, + ), + ), ], ), const SizedBox(height: 8), @@ -90,7 +99,6 @@ class _ConfigOptionsState extends State { child: Slider( min: 1, value: rateLimit.toDouble(), - label: '$rateLimit tps', max: 200, divisions: 199, onChanged: (r) => context @@ -98,6 +106,13 @@ class _ConfigOptionsState extends State { .rateLimit = r.toInt(), ), ), + SizedBox( + width: 71, + child: Text( + '$rateLimit tps', + textAlign: TextAlign.end, + ), + ), ], ), const SizedBox(height: 8), @@ -111,9 +126,6 @@ class _ConfigOptionsState extends State { Expanded( child: Slider( value: maxBufferLength.toDouble(), - label: maxBufferLength == 0 - ? 'Disabled' - : '$maxBufferLength tiles', max: 1000, divisions: 1000, onChanged: (r) => context @@ -121,6 +133,13 @@ class _ConfigOptionsState extends State { .maxBufferLength = r.toInt(), ), ), + SizedBox( + width: 71, + child: Text( + maxBufferLength == 0 ? 'Disabled' : '$maxBufferLength tiles', + textAlign: TextAlign.end, + ), + ), ], ), const Divider(height: 24), From 699b1304bbd9b830425c05f5ba07fe4ee83bce1a Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 16 Oct 2024 22:55:30 +0100 Subject: [PATCH 65/97] Improved example app --- example/lib/main.dart | 23 +- .../initialisation_error.dart | 2 +- .../download_progress_masker.dart | 7 +- .../region_selection/region_shape.dart | 10 +- .../config_options/config_options.dart | 20 +- .../confirmation_panel.dart | 115 ++++-- .../confirm_cancellation_dialog.dart | 39 ++ .../components/progress/colors.dart | 8 + .../components/progress/indicator_bars.dart | 52 +++ .../components/progress/indicator_text.dart | 180 +++++++++ .../statistics/components/timing/timing.dart | 115 ++++++ .../components/statistics/statistics.dart | 81 +++++ .../downloading_view_bottom_sheet.dart | 0 .../downloading/downloading_view_side.dart | 110 ++++++ .../secondary_view/layouts/side/side.dart | 14 +- .../components/numerical_input_row.dart | 137 ------- .../components/options_pane.dart | 44 --- .../components/region_information.dart | 286 --------------- .../components/start_download_button.dart | 100 ----- .../components/store_selector.dart | 56 --- .../configure_download.dart | 147 -------- .../confirm_cancellation_dialog.dart | 45 --- .../download/components/main_statistics.dart | 138 ------- .../multi_linear_progress_indicator.dart | 88 ----- .../old/download/components/stat_display.dart | 33 -- .../old/download/components/stats_table.dart | 86 ----- .../src/screens/old/download/download.dart | 343 ------------------ .../src/shared/state/download_provider.dart | 94 +++++ .../external/download_progress.dart | 15 +- lib/src/bulk_download/internal/manager.dart | 3 + .../internal/tile_loops/count.dart | 4 - .../internal/tile_loops/generate.dart | 4 - 32 files changed, 817 insertions(+), 1582 deletions(-) create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/components/confirm_cancellation_dialog.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/colors.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_bars.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/timing/timing.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart delete mode 100644 example/lib/src/screens/old/configure_download/components/numerical_input_row.dart delete mode 100644 example/lib/src/screens/old/configure_download/components/options_pane.dart delete mode 100644 example/lib/src/screens/old/configure_download/components/region_information.dart delete mode 100644 example/lib/src/screens/old/configure_download/components/start_download_button.dart delete mode 100644 example/lib/src/screens/old/configure_download/components/store_selector.dart delete mode 100644 example/lib/src/screens/old/configure_download/configure_download.dart delete mode 100644 example/lib/src/screens/old/download/components/confirm_cancellation_dialog.dart delete mode 100644 example/lib/src/screens/old/download/components/main_statistics.dart delete mode 100644 example/lib/src/screens/old/download/components/multi_linear_progress_indicator.dart delete mode 100644 example/lib/src/screens/old/download/components/stat_display.dart delete mode 100644 example/lib/src/screens/old/download/components/stats_table.dart delete mode 100644 example/lib/src/screens/old/download/download.dart create mode 100644 example/lib/src/shared/state/download_provider.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index eadeb96b..909b4434 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,11 +8,10 @@ import 'src/screens/import/import.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/main/main.dart'; import 'src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart'; -import 'src/screens/old/configure_download/configure_download.dart'; -import 'src/screens/old/download/download.dart'; import 'src/screens/store_editor/store_editor.dart'; 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/region_selection_provider.dart'; @@ -63,22 +62,6 @@ class _AppContainer extends StatelessWidget { fullscreenDialog: true, ), ), - ConfigureDownloadPopup.route: ( - std: null, - custom: (context, settings) => MaterialPageRoute( - builder: (context) => const ConfigureDownloadPopup(), - settings: settings, - fullscreenDialog: true, - ), - ), - DownloadPopup.route: ( - std: null, - custom: (context, settings) => MaterialPageRoute( - builder: (context) => const DownloadPopup(), - settings: settings, - fullscreenDialog: true, - ), - ), }; @override @@ -119,7 +102,9 @@ class _AppContainer extends StatelessWidget { ), ChangeNotifierProvider( create: (_) => DownloadConfigurationProvider(), - //lazy: true, + ), + ChangeNotifierProvider( + create: (_) => DownloadingProvider(), ), ], child: MaterialApp( diff --git a/example/lib/src/screens/initialisation_error/initialisation_error.dart b/example/lib/src/screens/initialisation_error/initialisation_error.dart index cd8c6238..cb579264 100644 --- a/example/lib/src/screens/initialisation_error/initialisation_error.dart +++ b/example/lib/src/screens/initialisation_error/initialisation_error.dart @@ -78,7 +78,7 @@ class InitialisationError extends StatelessWidget { await dir.delete(recursive: true); } on FileSystemException { showFailure(); - rethrow; + return; } runApp(const SizedBox.shrink()); // Destroy current app 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 a6c456d5..dfab651a 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 @@ -39,8 +39,11 @@ class _DownloadProgressMaskerState extends State { child: GreyscaleMasker( mapCamera: MapCamera.of(context), tileCoordinatesStream: dps - .where((e) => !e.latestTileEvent.isRepeat) - .map((e) => e.latestTileEvent.coordinates), + .where( + (e) => + e.latestTileEvent != null && !e.latestTileEvent!.isRepeat, + ) + .map((e) => e.latestTileEvent!.coordinates), minZoom: widget.minZoom, maxZoom: widget.maxZoom, tileSize: widget.tileSize, diff --git a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart index 1d7d888c..a5b93e3c 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart @@ -68,7 +68,7 @@ class RegionShape extends StatelessWidget { color: Theme.of(context) .colorScheme .surface - .withValues(alpha: 0.5), + .withAlpha(255 ~/ 2), ), ], ), @@ -88,7 +88,7 @@ class RegionShape extends StatelessWidget { bounds.southEast, bounds.southWest, ], - color: color.toColor().withValues(alpha: 0.7), + color: color.toColor().withAlpha(255 ~/ 2), ), ], ), @@ -98,7 +98,7 @@ class RegionShape extends StatelessWidget { point: center, radius: radius * 1000, useRadiusInMeter: true, - color: color.toColor().withValues(alpha: 0.7), + color: color.toColor().withAlpha(255 ~/ 2), ), ], ), @@ -108,7 +108,7 @@ class RegionShape extends StatelessWidget { .map( (o) => Polygon( points: o, - color: color.toColor().withValues(alpha: 0.7), + color: color.toColor().withAlpha(255 ~/ 2), ), ) .toList(growable: false), @@ -117,7 +117,7 @@ class RegionShape extends StatelessWidget { polygons: [ Polygon( points: outline, - color: color.toColor().withValues(alpha: 0.7), + color: color.toColor().withAlpha(255 ~/ 2), ), ], ), 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 a731cab3..409f8f9f 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 @@ -27,6 +27,12 @@ class _ConfigOptionsState extends State { context.select((p) => p.rateLimit); final maxBufferLength = context .select((p) => p.maxBufferLength); + final skipExistingTiles = + context.select( + (p) => p.skipExistingTiles, + ); + final skipSeaTiles = context + .select((p) => p.skipSeaTiles); return SingleChildScrollView( child: Column( @@ -151,7 +157,12 @@ class _ConfigOptionsState extends State { const SizedBox(width: 12), const Text('Skip Existing Tiles'), const Spacer(), - Switch.adaptive(value: true, onChanged: (value) {}), + Switch.adaptive( + value: skipExistingTiles, + onChanged: (v) => context + .read() + .skipExistingTiles = v, + ), ], ), Row( @@ -162,7 +173,12 @@ class _ConfigOptionsState extends State { const SizedBox(width: 12), const Text('Skip Sea Tiles'), const Spacer(), - Switch.adaptive(value: true, onChanged: (value) {}), + Switch.adaptive( + value: skipSeaTiles, + onChanged: (v) => context + .read() + .skipSeaTiles = v, + ), ], ), ], 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 4f4f7cd5..0da0ca0b 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 @@ -4,7 +4,9 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import '../../../../../../../shared/misc/store_metadata_keys.dart'; import '../../../../../../../shared/state/download_configuration_provider.dart'; +import '../../../../../../../shared/state/download_provider.dart'; import '../../../../../../../shared/state/region_selection_provider.dart'; class ConfirmationPanel extends StatefulWidget { @@ -15,16 +17,23 @@ class ConfirmationPanel extends StatefulWidget { } class _ConfirmationPanelState extends State { - DownloadableRegion? _prevDownloadableRegion; + DownloadableRegion? _prevTileCountableRegion; late Future _tileCount; - void _updateTileCount() { - _tileCount = const FMTCStore('').download.check(_prevDownloadableRegion!); - setState(() {}); - } + bool _loadingDownloader = false; @override Widget build(BuildContext context) { + final regions = context + .select>( + (p) => p.constructedRegions, + ) + .keys + .toList(growable: false); + final minZoom = + context.select((p) => p.minZoom); + final maxZoom = + context.select((p) => p.maxZoom); final startTile = context.select((p) => p.startTile); final endTile = @@ -35,31 +44,21 @@ class _ConfirmationPanelState extends State { ) != 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), + final tileCountableRegion = MultiRegion(regions).toDownloadable( + minZoom: minZoom, + maxZoom: 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; + if (_prevTileCountableRegion == null || + tileCountableRegion.originalRegion != + _prevTileCountableRegion!.originalRegion || + tileCountableRegion.minZoom != _prevTileCountableRegion!.minZoom || + tileCountableRegion.maxZoom != _prevTileCountableRegion!.maxZoom || + tileCountableRegion.start != _prevTileCountableRegion!.start || + tileCountableRegion.end != _prevTileCountableRegion!.end) { + _prevTileCountableRegion = tileCountableRegion; _updateTileCount(); } @@ -179,13 +178,71 @@ class _ConfirmationPanelState extends State { height: 46, width: double.infinity, child: FilledButton.icon( - onPressed: !hasSelectedStoreName ? null : () {}, - label: const Text('Start Download'), - icon: const Icon(Icons.download), + onPressed: !hasSelectedStoreName || _loadingDownloader + ? null + : _startDownload, + label: _loadingDownloader + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(), + ) + : const Text('Start Download'), + icon: _loadingDownloader ? null : const Icon(Icons.download), ), ), ], ), ); } + + void _updateTileCount() { + _tileCount = const FMTCStore('').download.check(_prevTileCountableRegion!); + setState(() {}); + } + + Future _startDownload() async { + setState(() => _loadingDownloader = true); + + final downloadingProvider = context.read(); + final regionSelection = context.read(); + final downloadConfiguration = context.read(); + + final store = FMTCStore(downloadConfiguration.selectedStoreName!); + final urlTemplate = + (await store.metadata.read)[StoreMetadataKeys.urlTemplate.key]; + + if (!mounted) return; + + final downloadableRegion = MultiRegion( + regionSelection.constructedRegions.keys.toList(growable: false), + ).toDownloadable( + minZoom: downloadConfiguration.minZoom, + maxZoom: downloadConfiguration.maxZoom, + start: downloadConfiguration.startTile, + end: downloadConfiguration.endTile, + options: TileLayer( + urlTemplate: urlTemplate, + userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', + ), + ); + + final downloadStream = store.download.startForeground( + region: downloadableRegion, + parallelThreads: downloadConfiguration.parallelThreads, + maxBufferLength: downloadConfiguration.maxBufferLength, + skipExistingTiles: downloadConfiguration.skipExistingTiles, + skipSeaTiles: downloadConfiguration.skipSeaTiles, + rateLimit: downloadConfiguration.rateLimit, + ); + + downloadingProvider.assignDownload( + storeName: downloadConfiguration.selectedStoreName!, + downloadableRegion: downloadableRegion, + stream: downloadStream, + ); + + // 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/downloading/components/confirm_cancellation_dialog.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/confirm_cancellation_dialog.dart new file mode 100644 index 00000000..228e3e98 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/confirm_cancellation_dialog.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../shared/state/download_provider.dart'; + +class ConfirmCancellationDialog extends StatefulWidget { + const ConfirmCancellationDialog({super.key}); + + @override + State createState() => + _ConfirmCancellationDialogState(); +} + +class _ConfirmCancellationDialogState extends State { + bool _isCancelling = false; + + @override + Widget build(BuildContext context) => AlertDialog.adaptive( + icon: const Icon(Icons.cancel), + title: const Text('Cancel download?'), + content: const Text('Any tiles already downloaded will not be removed'), + actions: _isCancelling + ? [const CircularProgressIndicator.adaptive()] + : [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Continue download'), + ), + FilledButton( + onPressed: () async { + setState(() => _isCancelling = true); + await context.read().cancel(); + if (context.mounted) Navigator.of(context).pop(true); + }, + child: const Text('Cancel download'), + ), + ], + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/colors.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/colors.dart new file mode 100644 index 00000000..b5fa8d42 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/colors.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +class DownloadingProgressIndicatorColors { + static final pendingColor = Colors.grey[350]!; + static const failedColor = Colors.red; + static const skippedColor = Colors.orange; + static const successfulColor = Colors.green; +} diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_bars.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_bars.dart new file mode 100644 index 00000000..82e9f4ae --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_bars.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../../../shared/state/download_provider.dart'; +import 'colors.dart'; + +class ProgressIndicatorBars extends StatelessWidget { + const ProgressIndicatorBars({super.key}); + + static const double _barHeight = 14; + + @override + Widget build(BuildContext context) { + final successful = context.select( + (p) => p.latestEvent.successfulTiles / p.latestEvent.maxTiles, + ); + final skipped = context.select( + (p) => p.latestEvent.skippedTiles / p.latestEvent.maxTiles, + ); + final failed = context.select( + (p) => p.latestEvent.failedTiles / p.latestEvent.maxTiles, + ); + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: IntrinsicHeight( + child: Stack( + children: [ + LinearProgressIndicator( + value: successful + skipped + failed, + backgroundColor: DownloadingProgressIndicatorColors.pendingColor, + color: DownloadingProgressIndicatorColors.failedColor, + minHeight: _barHeight, + ), + LinearProgressIndicator( + value: successful + skipped, + backgroundColor: Colors.transparent, + color: DownloadingProgressIndicatorColors.skippedColor, + minHeight: _barHeight, + ), + LinearProgressIndicator( + value: successful, + backgroundColor: Colors.transparent, + color: DownloadingProgressIndicatorColors.successfulColor, + minHeight: _barHeight, + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart new file mode 100644 index 00000000..db8bf066 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../../../shared/misc/exts/size_formatter.dart'; +import '../../../../../../../../../shared/state/download_provider.dart'; +import 'colors.dart'; + +class ProgressIndicatorText extends StatefulWidget { + const ProgressIndicatorText({super.key}); + + @override + State createState() => _ProgressIndicatorTextState(); +} + +class _ProgressIndicatorTextState extends State { + late final Timer _rawPercentAlternator; + bool _usePercentages = false; + + @override + void initState() { + super.initState(); + _rawPercentAlternator = Timer.periodic( + const Duration(seconds: 2), + (_) => setState(() => _usePercentages = !_usePercentages), + ); + } + + @override + void dispose() { + _rawPercentAlternator.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cachedTilesCount = context.select( + (p) => p.latestEvent.cachedTiles - p.latestEvent.bufferedTiles, + ); + final cachedTilesSize = context.select( + (p) => p.latestEvent.cachedSize - p.latestEvent.bufferedSize, + ) * + 1024; + + final bufferedTilesCount = context + .select((p) => p.latestEvent.bufferedTiles); + final bufferedTilesSize = context.select( + (p) => p.latestEvent.bufferedSize, + ) * + 1024; + + final skippedExistingTilesCount = context + .select((p) => p.skippedExistingTileCount); + final skippedExistingTilesSize = context + .select((p) => p.skippedExistingTileSize); + + final skippedSeaTilesCount = + context.select((p) => p.skippedSeaTileCount); + final skippedSeaTilesSize = + context.select((p) => p.skippedSeaTileSize); + + final failedTilesCount = context + .select((p) => p.latestEvent.failedTiles); + + final pendingTilesCount = context + .select((p) => p.latestEvent.remainingTiles); + + final maxTilesCount = + context.select((p) => p.latestEvent.maxTiles); + + return Column( + children: [ + _TextRow( + color: DownloadingProgressIndicatorColors.successfulColor, + type: 'Successful', + statistic: _usePercentages + ? '${(((cachedTilesCount + bufferedTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}% ' + : '${cachedTilesCount + bufferedTilesCount} tiles (${(cachedTilesSize + bufferedTilesSize).asReadableSize})', + ), + const SizedBox(height: 4), + _TextRow( + type: 'Cached', + statistic: _usePercentages + ? '${((cachedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}% ' + : '$cachedTilesCount tiles (${cachedTilesSize.asReadableSize})', + ), + const SizedBox(height: 4), + _TextRow( + type: 'Buffered', + statistic: _usePercentages + ? '${((bufferedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$bufferedTilesCount tiles (${bufferedTilesSize.asReadableSize})', + ), + const SizedBox(height: 4), + _TextRow( + color: DownloadingProgressIndicatorColors.skippedColor, + type: 'Skipped', + statistic: _usePercentages + ? '${(((skippedSeaTilesCount + skippedExistingTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '${skippedSeaTilesCount + skippedExistingTilesCount} tiles (${(skippedSeaTilesSize + skippedExistingTilesSize).asReadableSize})', + ), + const SizedBox(height: 4), + _TextRow( + type: 'Existing', + statistic: _usePercentages + ? '${((skippedExistingTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$skippedExistingTilesCount tiles (${skippedExistingTilesSize.asReadableSize})', + ), + const SizedBox(height: 4), + _TextRow( + type: 'Sea Tiles', + statistic: _usePercentages + ? '${((skippedSeaTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$skippedSeaTilesCount tiles (${skippedSeaTilesSize.asReadableSize})', + ), + const SizedBox(height: 4), + _TextRow( + color: DownloadingProgressIndicatorColors.failedColor, + type: 'Failed', + statistic: _usePercentages + ? '${((failedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$failedTilesCount tiles', + ), + const SizedBox(height: 4), + _TextRow( + color: DownloadingProgressIndicatorColors.pendingColor, + type: 'Pending', + statistic: _usePercentages + ? '${((pendingTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$pendingTilesCount/$maxTilesCount tiles', + ), + ], + ); + } +} + +class _TextRow extends StatelessWidget { + const _TextRow({ + this.color, + required this.type, + required this.statistic, + }); + + final Color? color; + final String type; + final String statistic; + + @override + Widget build(BuildContext context) => Row( + children: [ + if (color case final color?) + Container( + height: 16, + width: 16, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + ) + else + const SizedBox(width: 28), + const SizedBox(width: 8), + Text( + type, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontStyle: + color == null ? FontStyle.italic : FontStyle.normal, + ), + ), + const Spacer(), + Text( + statistic, + style: TextStyle( + fontStyle: color == null ? FontStyle.italic : FontStyle.normal, + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/timing/timing.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/timing/timing.dart new file mode 100644 index 00000000..24ff71bb --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/timing/timing.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../../../shared/state/download_configuration_provider.dart'; +import '../../../../../../../../../shared/state/download_provider.dart'; + +class TimingStats extends StatefulWidget { + const TimingStats({super.key}); + + @override + State createState() => _TimingStatsState(); +} + +class _TimingStatsState extends State { + @override + Widget build(BuildContext context) { + final estRemainingDuration = context.select( + (p) => p.latestEvent.estRemainingDuration, + ); + + return Column( + children: [ + Row( + children: [ + const Icon(Icons.timer_outlined, size: 32), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatDuration( + context.select( + (p) => p.latestEvent.elapsedDuration, + ), + ), + style: Theme.of(context).textTheme.titleLarge, + ), + const Text('duration elapsed'), + ], + ), + const Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + children: [ + Text( + context + .select( + (p) => p.latestEvent.tilesPerSecond, + ) + .toStringAsFixed(0), + style: Theme.of(context).textTheme.titleLarge, + ), + if (context.select( + (p) => p.latestEvent.tilesPerSecond, + ) >= + context.select( + (p) => p.rateLimit, + ) - + 2) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon( + Icons.publish, + color: Colors.orange[700], + ), + ), + ], + ), + const Text('tiles per second'), + ], + ), + const SizedBox(width: 8), + const Icon(Icons.speed, size: 32), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.timelapse, size: 32), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + switch (estRemainingDuration) { + <= Duration.zero => 'almost done', + < const Duration(minutes: 1) => '< 1 min', + < const Duration(minutes: 60) => + 'about ${estRemainingDuration.inMinutes} mins', + _ => 'about ${estRemainingDuration.inHours} hours', + }, + style: Theme.of(context).textTheme.titleLarge, + ), + const Text('est. duration remaining'), + ], + ), + ], + ), + ], + ); + } + + String _formatDuration( + Duration duration, { + bool showSeconds = true, + }) { + final hours = duration.inHours.toString().padLeft(2, '0'); + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + + return '${hours}h ${minutes}m${showSeconds ? ' ${seconds}s' : ''}'; + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart new file mode 100644 index 00000000..9bbadcb1 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../shared/state/download_provider.dart'; +import '../../../../../../../shared/state/region_selection_provider.dart'; +import 'components/progress/indicator_bars.dart'; +import 'components/progress/indicator_text.dart'; +import 'components/timing/timing.dart'; + +class DownloadStatistics extends StatelessWidget { + const DownloadStatistics({super.key}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (context.select( + (p) => p.latestEvent.isComplete, + )) + IntrinsicHeight( + child: Row( + children: [ + Text( + 'Downloading complete', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Spacer(), + SizedBox( + height: double.infinity, + child: FilledButton.icon( + onPressed: () { + context.read() + ..isDownloadSetupPanelVisible = false + ..clearConstructedRegions() + ..clearCoordinates(); + context.read().reset(); + }, + label: const Text('Reset'), + icon: const Icon(Icons.done_all), + ), + ), + ], + ), + ) + else if (context.select((p) => p.isPaused)) + Row( + children: [ + Text( + 'Downloading paused', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Spacer(), + const Icon(Icons.pause_circle, size: 36), + ], + ) + else + Row( + children: [ + Text( + 'Downloading map', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Spacer(), + const Padding( + padding: EdgeInsets.all(2), + child: SizedBox.square( + dimension: 32, + child: CircularProgressIndicator.adaptive(), + ), + ), + ], + ), + const SizedBox(height: 24), + const TimingStats(), + const SizedBox(height: 24), + const ProgressIndicatorBars(), + const SizedBox(height: 16), + const ProgressIndicatorText(), + ], + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart new file mode 100644 index 00000000..e69de29b diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart new file mode 100644 index 00000000..976cd5c3 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../../../shared/state/download_provider.dart'; +import '../../../../../shared/state/region_selection_provider.dart'; +import '../../layouts/side/components/panel.dart'; +import 'components/confirm_cancellation_dialog.dart'; +import 'components/statistics/statistics.dart'; + +class DownloadingViewSide extends StatefulWidget { + const DownloadingViewSide({ + super.key, + }); + + @override + State createState() => _DownloadingViewSideState(); +} + +class _DownloadingViewSideState extends State { + bool _isPausing = false; + + @override + Widget build(BuildContext context) => Column( + children: [ + IntrinsicHeight( + child: Row( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(4), + child: IconButton( + onPressed: () async { + if (context + .read() + .latestEvent + .isComplete) { + context.read() + ..isDownloadSetupPanelVisible = false + ..clearConstructedRegions() + ..clearCoordinates(); + context.read().reset(); + return; + } + + await showDialog( + context: context, + builder: (context) => const ConfirmCancellationDialog(), + ); + }, + icon: const Icon(Icons.cancel), + tooltip: 'Cancel Download', + ), + ), + const SizedBox(width: 12), + if (context.select( + (p) => !p.latestEvent.isComplete, + )) + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(4), + child: _isPausing + ? const AspectRatio( + aspectRatio: 1, + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(), + ), + ), + ) + : context.select( + (p) => p.isPaused, + ) + ? IconButton( + onPressed: () => context + .read() + .resume(), + icon: const Icon(Icons.play_arrow), + tooltip: 'Resume Download', + ) + : IconButton( + onPressed: () async { + setState(() => _isPausing = true); + await context + .read() + .pause(); + setState(() => _isPausing = false); + }, + icon: const Icon(Icons.pause), + tooltip: 'Pause Download', + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const Expanded( + child: SideViewPanel( + child: SingleChildScrollView(child: DownloadStatistics()), + ), + ), + const SizedBox(height: 16), + ], + ); +} 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 f93a6985..60efc8cc 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 @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../../../../shared/state/download_provider.dart'; 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/region_selection/region_selection_view_side.dart'; @@ -44,11 +46,13 @@ class SecondaryViewSide extends StatelessWidget { ), child: switch (selectedTab) { 0 => HomeViewSide(constraints: constraints), - 1 => context.select( - (p) => p.isDownloadSetupPanelVisible, - ) - ? const DownloadConfigurationViewSide() - : const RegionSelectionViewSide(), + 1 => context.select((p) => p.isFocused) + ? const DownloadingViewSide() + : context.select( + (p) => p.isDownloadSetupPanelVisible, + ) + ? const DownloadConfigurationViewSide() + : const RegionSelectionViewSide(), _ => Placeholder(key: ValueKey(selectedTab)), }, ), diff --git a/example/lib/src/screens/old/configure_download/components/numerical_input_row.dart b/example/lib/src/screens/old/configure_download/components/numerical_input_row.dart deleted file mode 100644 index 237d165e..00000000 --- a/example/lib/src/screens/old/configure_download/components/numerical_input_row.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -import '../../../../shared/state/download_configuration_provider.dart'; - -class NumericalInputRow extends StatefulWidget { - const NumericalInputRow({ - super.key, - required this.label, - required this.suffixText, - required this.value, - required this.min, - required this.max, - this.maxEligibleTilesPreview, - required this.onChanged, - }); - - final String label; - final String suffixText; - final int Function(DownloadConfigurationProvider provider) value; - final int min; - final int? max; - final int? maxEligibleTilesPreview; - final void Function(DownloadConfigurationProvider provider, int value) - onChanged; - - @override - State createState() => _NumericalInputRowState(); -} - -class _NumericalInputRowState extends State { - TextEditingController? tec; - - @override - Widget build(BuildContext context) => - Selector( - selector: (context, provider) => widget.value(provider), - builder: (context, currentValue, _) { - tec ??= TextEditingController(text: currentValue.toString()); - - return Row( - children: [ - Text(widget.label), - const Spacer(), - if (widget.maxEligibleTilesPreview != null) ...[ - IconButton( - icon: const Icon(Icons.visibility), - disabledColor: Colors.green, - tooltip: currentValue > widget.maxEligibleTilesPreview! - ? 'Tap to enable following download live' - : 'Eligible to follow download live', - onPressed: currentValue > widget.maxEligibleTilesPreview! - ? () { - widget.onChanged( - context.read(), - widget.maxEligibleTilesPreview!, - ); - tec!.text = widget.maxEligibleTilesPreview.toString(); - } - : null, - ), - const SizedBox(width: 8), - ], - if (widget.max != null) ...[ - Tooltip( - message: currentValue == widget.max - ? 'Limited in the example app' - : '', - child: Icon( - Icons.lock, - color: currentValue == widget.max - ? Colors.amber - : Colors.white.withValues(alpha: 0.2), - ), - ), - const SizedBox(width: 16), - ], - IntrinsicWidth( - child: TextFormField( - controller: tec, - textAlign: TextAlign.end, - keyboardType: TextInputType.number, - decoration: InputDecoration( - isDense: true, - counterText: '', - suffixText: ' ${widget.suffixText}', - ), - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - _NumericalRangeFormatter( - min: widget.min, - max: widget.max ?? double.maxFinite.toInt(), - ), - ], - onChanged: (newVal) => widget.onChanged( - context.read(), - int.tryParse(newVal) ?? currentValue, - ), - ), - ), - ], - ); - }, - ); -} - -class _NumericalRangeFormatter extends TextInputFormatter { - const _NumericalRangeFormatter({required this.min, required this.max}); - final int min; - final int max; - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - if (newValue.text.isEmpty) return newValue; - - final int parsed = int.parse(newValue.text); - - if (parsed < min) { - return TextEditingValue.empty.copyWith( - text: min.toString(), - selection: TextSelection.collapsed(offset: min.toString().length), - ); - } - if (parsed > max) { - return TextEditingValue.empty.copyWith( - text: max.toString(), - selection: TextSelection.collapsed(offset: max.toString().length), - ); - } - - return newValue; - } -} diff --git a/example/lib/src/screens/old/configure_download/components/options_pane.dart b/example/lib/src/screens/old/configure_download/components/options_pane.dart deleted file mode 100644 index 8aa9cd08..00000000 --- a/example/lib/src/screens/old/configure_download/components/options_pane.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../shared/misc/exts/interleave.dart'; - -class OptionsPane extends StatelessWidget { - const OptionsPane({ - super.key, - required this.label, - required this.children, - this.interPadding = 8, - }); - - final String label; - final Iterable children; - final double interPadding; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 14), - child: Text(label), - ), - const SizedBox.square(dimension: 4), - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: children.singleOrNull ?? - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: children - .interleave(SizedBox.square(dimension: interPadding)) - .toList(), - ), - ), - ), - ], - ); -} diff --git a/example/lib/src/screens/old/configure_download/components/region_information.dart b/example/lib/src/screens/old/configure_download/components/region_information.dart deleted file mode 100644 index 06a75de6..00000000 --- a/example/lib/src/screens/old/configure_download/components/region_information.dart +++ /dev/null @@ -1,286 +0,0 @@ -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:dart_earcut/dart_earcut.dart'; -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:latlong2/latlong.dart'; - -class RegionInformation extends StatefulWidget { - const RegionInformation({ - super.key, - required this.region, - required this.maxTiles, - }); - - final DownloadableRegion region; - final int? maxTiles; - - @override - State createState() => _RegionInformationState(); -} - -class _RegionInformationState extends State { - final distance = const Distance(roundResult: false).distance; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...widget.region.when( - rectangle: (rectangleRegion) { - final rectangle = rectangleRegion.originalRegion; - - return [ - const Text('TOTAL AREA'), - Text( - '${(distance(rectangle.bounds.northWest, rectangle.bounds.northEast) * distance(rectangle.bounds.northEast, rectangle.bounds.southEast) / 1000000).toStringAsFixed(3)} km²', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('APPROX. NORTH WEST'), - Text( - '${rectangle.bounds.northWest.latitude.toStringAsFixed(3)}, ${rectangle.bounds.northWest.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('APPROX. SOUTH EAST'), - Text( - '${rectangle.bounds.southEast.latitude.toStringAsFixed(3)}, ${rectangle.bounds.southEast.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ]; - }, - circle: (circleRegion) { - final circle = circleRegion.originalRegion; - - return [ - const Text('TOTAL AREA'), - Text( - '${(pi * pow(circle.radius, 2)).toStringAsFixed(3)} km²', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('RADIUS'), - Text( - '${circle.radius.toStringAsFixed(2)} km', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('APPROX. CENTER'), - Text( - '${circle.center.latitude.toStringAsFixed(3)}, ${circle.center.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ]; - }, - line: (lineRegion) { - final line = lineRegion.originalRegion; - - double totalDistance = 0; - - for (int i = 0; i < line.line.length - 1; i++) { - totalDistance += - distance(line.line[i], line.line[i + 1]); - } - - return [ - const Text('LINE LENGTH'), - Text( - '${(totalDistance / 1000).toStringAsFixed(3)} km', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('FIRST COORD'), - Text( - '${line.line[0].latitude.toStringAsFixed(3)}, ${line.line[0].longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('LAST COORD'), - Text( - '${line.line.last.latitude.toStringAsFixed(3)}, ${line.line.last.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ]; - }, - customPolygon: (customPolygonRegion) { - final customPolygon = customPolygonRegion.originalRegion; - - double area = 0; - - for (final triangle in Earcut.triangulateFromPoints( - customPolygon.outline - .map(const Epsg3857().projection.project), - ).map(customPolygon.outline.elementAt).slices(3)) { - final a = distance(triangle[0], triangle[1]); - final b = distance(triangle[1], triangle[2]); - final c = distance(triangle[2], triangle[0]); - - area += 0.25 * - sqrt( - 4 * a * a * b * b - pow(a * a + b * b - c * c, 2), - ); - } - - return [ - const Text('TOTAL AREA'), - Text( - '${(area / 1000000).toStringAsFixed(3)} km²', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ]; - }, - multi: (_) => throw UnsupportedError( - '`MultiRegion` is not supported in the example app', - ), - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const Text('ZOOM LEVELS'), - Text( - '${widget.region.minZoom} - ${widget.region.maxZoom}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('TOTAL TILES'), - if (widget.maxTiles case final maxTiles?) - Text( - NumberFormat('###,###').format(maxTiles), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ) - else - Padding( - padding: const EdgeInsets.only(top: 4), - child: SizedBox( - height: 36, - width: 36, - child: Center( - child: SizedBox( - height: 28, - width: 28, - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.secondary, - ), - ), - ), - ), - ), - const SizedBox(height: 10), - const Text('TILES RANGE'), - if (widget.region.start == 1 && widget.region.end == null) - const Text( - '*', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ) - else - Text( - '${NumberFormat('###,###').format(widget.region.start)} - ${widget.region.end != null ? NumberFormat('###,###').format(widget.region.end) : '*'}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ], - ), - ], - ), - 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.', - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - ], - ); -} 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 deleted file mode 100644 index 847c49ce..00000000 --- a/example/lib/src/screens/old/configure_download/components/start_download_button.dart +++ /dev/null @@ -1,100 +0,0 @@ -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'; -import 'package:provider/provider.dart'; - -import '../../../../shared/misc/store_metadata_keys.dart'; -import '../../../../shared/state/download_configuration_provider.dart'; -import '../../download/download.dart'; - -class StartDownloadButton extends StatelessWidget { - const StartDownloadButton({ - super.key, - required this.region, - required this.maxTiles, - }); - - final DownloadableRegion region; - final int? maxTiles; - - @override - Widget build(BuildContext context) => - Selector( - selector: (context, provider) => provider.selectedStoreName, - builder: (context, selectedStore, child) { - final enabled = selectedStore != null && maxTiles != null; - - return IgnorePointer( - ignoring: !enabled, - child: AnimatedOpacity( - opacity: enabled ? 1 : 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: child, - ), - ); - }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - FloatingActionButton.extended( - onPressed: () async { - final configureDownloadProvider = - context.read(); - - 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'), - ), - ); - return; - } - - final urlTemplate = (await selectedStore - .metadata.read)[StoreMetadataKeys.urlTemplate.key]!; - - if (!context.mounted) return; - - unawaited( - Navigator.of(context).popAndPushNamed( - DownloadPopup.route, - arguments: ( - downloadProgress: selectedStore.download.startForeground( - region: region.originalRegion.toDownloadable( - minZoom: region.minZoom, - maxZoom: region.maxZoom, - start: region.start, - end: region.end, - options: TileLayer( - urlTemplate: urlTemplate, - userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', - ), - ), - parallelThreads: - configureDownloadProvider.parallelThreads, - maxBufferLength: - configureDownloadProvider.maxBufferLength, - skipExistingTiles: - configureDownloadProvider.skipExistingTiles, - skipSeaTiles: configureDownloadProvider.skipSeaTiles, - rateLimit: configureDownloadProvider.rateLimit, - ), - maxTiles: maxTiles! - ), - ), - ); - }, - label: const Text('Start Download'), - icon: const Icon(Icons.save), - ), - ], - ), - ); -} 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 deleted file mode 100644 index 559fc3a0..00000000 --- a/example/lib/src/screens/old/configure_download/components/store_selector.dart +++ /dev/null @@ -1,56 +0,0 @@ -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'; - -class StoreSelector extends StatefulWidget { - const StoreSelector({super.key}); - - @override - State createState() => _StoreSelectorState(); -} - -class _StoreSelectorState extends State { - @override - Widget build(BuildContext context) => Row( - children: [ - const Text('Store'), - const Spacer(), - IntrinsicWidth( - 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.storeName, - child: Text(e.storeName), - ), - ) - .toList(); - final text = snapshot.data == null - ? 'Loading...' - : snapshot.data!.isEmpty - ? 'None Available' - : 'None Selected'; - - return DropdownButton( - items: items, - onChanged: (store) => context - .read() - .selectedStoreName = store, - value: selectedStore, - hint: Text(text), - padding: const EdgeInsets.only(left: 12), - ); - }, - ), - ), - ), - ], - ); -} diff --git a/example/lib/src/screens/old/configure_download/configure_download.dart b/example/lib/src/screens/old/configure_download/configure_download.dart deleted file mode 100644 index caafbd34..00000000 --- a/example/lib/src/screens/old/configure_download/configure_download.dart +++ /dev/null @@ -1,147 +0,0 @@ -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:provider/provider.dart'; - -import '../../../shared/misc/exts/interleave.dart'; -import '../../../shared/state/region_selection_provider.dart'; -import 'components/numerical_input_row.dart'; -import 'components/options_pane.dart'; -import 'components/region_information.dart'; -import 'components/start_download_button.dart'; -import 'components/store_selector.dart'; -import '../../../shared/state/download_configuration_provider.dart'; - -class ConfigureDownloadPopup extends StatefulWidget { - const ConfigureDownloadPopup({super.key}); - - static const String route = '/download/configure'; - - @override - State createState() => _ConfigureDownloadPopupState(); -} - -class _ConfigureDownloadPopupState extends State { - DownloadableRegion? region; - int? maxTiles; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - final provider = context.read(); - /*const FMTCStore('') - .download - .check( - region ??= provider.region!.toDownloadable( - minZoom: provider.minZoom, - maxZoom: provider.maxZoom, - start: provider.startTile, - end: provider.endTile, - options: TileLayer(), - ), - ) - .then((v) => setState(() => maxTiles = v));*/ - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Configure Bulk Download')), - floatingActionButton: StartDownloadButton( - region: region!, - maxTiles: maxTiles, - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox.shrink(), - RegionInformation( - region: region!, - maxTiles: maxTiles, - ), - const Divider(thickness: 2, height: 8), - const OptionsPane( - label: 'STORE DIRECTORY', - children: [StoreSelector()], - ), - OptionsPane( - label: 'PERFORMANCE FACTORS', - children: [ - NumericalInputRow( - label: 'Parallel Threads', - suffixText: 'threads', - value: (provider) => provider.parallelThreads, - min: 1, - max: 10, - onChanged: (provider, value) => - provider.parallelThreads = value, - ), - NumericalInputRow( - label: 'Rate Limit', - suffixText: 'max. tps', - value: (provider) => provider.rateLimit, - min: 1, - max: 300, - maxEligibleTilesPreview: 20, - onChanged: (provider, value) => - provider.rateLimit = value, - ), - NumericalInputRow( - label: 'Tile Buffer Length', - suffixText: 'max. tiles', - value: (provider) => provider.maxBufferLength, - min: 0, - max: null, - onChanged: (provider, value) => - provider.maxBufferLength = value, - ), - ], - ), - OptionsPane( - label: 'SKIP TILES', - children: [ - Row( - children: [ - const Text('Skip Existing Tiles'), - const Spacer(), - Switch.adaptive( - value: context - .select( - (provider) => provider.skipExistingTiles, - ), - onChanged: (val) => context - .read() - .skipExistingTiles = val, - activeColor: Theme.of(context).colorScheme.primary, - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Skip Sea Tiles'), - const Spacer(), - Switch.adaptive( - value: context - .select( - (provider) => provider.skipSeaTiles, - ), - onChanged: (val) => context - .read() - .skipSeaTiles = val, - activeColor: Theme.of(context).colorScheme.primary, - ), - ], - ), - ], - ), - const SizedBox(height: 72), - ].interleave(const SizedBox.square(dimension: 16)).toList(), - ), - ), - ), - ); -} 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 deleted file mode 100644 index 9114acf5..00000000 --- a/example/lib/src/screens/old/download/components/confirm_cancellation_dialog.dart +++ /dev/null @@ -1,45 +0,0 @@ -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'; - -class ConfirmCancellationDialog extends StatefulWidget { - const ConfirmCancellationDialog({super.key}); - - @override - State createState() => - _ConfirmCancellationDialogState(); -} - -class _ConfirmCancellationDialogState extends State { - bool isCancelling = false; - - @override - Widget build(BuildContext context) => AlertDialog.adaptive( - icon: const Icon(Icons.cancel), - title: const Text('Cancel download?'), - content: const Text('Any tiles already downloaded will not be removed'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Continue download'), - ), - if (isCancelling) - const CircularProgressIndicator.adaptive() - else - FilledButton( - onPressed: () async { - setState(() => isCancelling = true); - 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 deleted file mode 100644 index b615db27..00000000 --- a/example/lib/src/screens/old/download/components/main_statistics.dart +++ /dev/null @@ -1,138 +0,0 @@ -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'; -import 'stat_display.dart'; - -class MainStatistics extends StatefulWidget { - const MainStatistics({ - super.key, - required this.download, - required this.maxTiles, - }); - - final DownloadProgress? download; - final int maxTiles; - - @override - State createState() => _MainStatisticsState(); -} - -class _MainStatisticsState extends State { - @override - Widget build(BuildContext context) => IntrinsicWidth( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - RepaintBoundary( - child: Text( - '${widget.download?.attemptedTiles ?? 0}/${widget.maxTiles} (${widget.download?.percentageProgress.toStringAsFixed(2) ?? 0}%)', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 16), - StatDisplay( - statistic: - '${widget.download?.elapsedDuration.toString().split('.')[0] ?? '0:00:00'} ' - '/ ${widget.download?.estTotalDuration.toString().split('.')[0] ?? '0:00:00'}', - description: 'elapsed / estimated total duration', - ), - StatDisplay( - statistic: widget.download?.estRemainingDuration - .toString() - .split('.')[0] ?? - '0:00:00', - description: 'estimated remaining duration', - ), - RepaintBoundary( - child: Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.download?.tilesPerSecond.toStringAsFixed(2) ?? - '...', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: - widget.download?.isTPSArtificiallyCapped ?? false - ? Colors.amber - : null, - ), - ), - if (widget.download?.isTPSArtificiallyCapped ?? - false) ...[ - const SizedBox(width: 8), - const Icon(Icons.lock_clock, color: Colors.amber), - ], - ], - ), - Text( - 'approx. tiles per second', - style: TextStyle( - fontSize: 16, - color: widget.download?.isTPSArtificiallyCapped ?? false - ? Colors.amber - : null, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - if (!(widget.download?.isComplete ?? false)) - RepaintBoundary( - 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), - ), - ], - ); - }, - ), - ), - if (widget.download?.isComplete ?? false) - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () => Navigator.of(context).pop(), - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Text('Exit'), - ), - ), - ), - ], - ), - ); -} diff --git a/example/lib/src/screens/old/download/components/multi_linear_progress_indicator.dart b/example/lib/src/screens/old/download/components/multi_linear_progress_indicator.dart deleted file mode 100644 index 1881fc65..00000000 --- a/example/lib/src/screens/old/download/components/multi_linear_progress_indicator.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; - -typedef IndividualProgress = ({num value, Color color, Widget? child}); - -class MulitLinearProgressIndicator extends StatefulWidget { - const MulitLinearProgressIndicator({ - super.key, - required this.progresses, - this.maxValue = 1, - this.backgroundChild, - this.height = 24, - this.radius, - this.childAlignment = Alignment.centerRight, - this.animationDuration = const Duration(milliseconds: 500), - }); - - final List progresses; - final num maxValue; - final Widget? backgroundChild; - final double height; - final BorderRadiusGeometry? radius; - final AlignmentGeometry childAlignment; - final Duration animationDuration; - - @override - State createState() => - _MulitLinearProgressIndicatorState(); -} - -class _MulitLinearProgressIndicatorState - extends State { - @override - Widget build(BuildContext context) => RepaintBoundary( - child: LayoutBuilder( - builder: (context, constraints) => ClipRRect( - borderRadius: - widget.radius ?? BorderRadius.circular(widget.height / 2), - child: SizedBox( - height: widget.height, - width: constraints.maxWidth, - child: Stack( - children: [ - Positioned.fill( - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: widget.radius ?? - BorderRadius.circular(widget.height / 2), - border: Border.all( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - padding: EdgeInsets.symmetric( - vertical: 2, - horizontal: widget.height / 2, - ), - alignment: widget.childAlignment, - child: widget.backgroundChild, - ), - ), - ...widget.progresses.map( - (e) => AnimatedPositioned( - height: widget.height, - left: 0, - width: (constraints.maxWidth / widget.maxValue) * e.value, - duration: widget.animationDuration, - child: Container( - decoration: BoxDecoration( - color: e.color, - borderRadius: widget.radius ?? - BorderRadius.circular(widget.height / 2), - ), - padding: EdgeInsets.symmetric( - vertical: 2, - horizontal: widget.height / 2, - ), - alignment: widget.childAlignment, - child: e.child, - ), - ), - ), - ], - ), - ), - ), - ), - ); -} diff --git a/example/lib/src/screens/old/download/components/stat_display.dart b/example/lib/src/screens/old/download/components/stat_display.dart deleted file mode 100644 index 3592c850..00000000 --- a/example/lib/src/screens/old/download/components/stat_display.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -class StatDisplay extends StatelessWidget { - const StatDisplay({ - super.key, - required this.statistic, - required this.description, - }); - - final String statistic; - final String description; - - @override - Widget build(BuildContext context) => RepaintBoundary( - child: Column( - children: [ - Text( - statistic, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - Text( - description, - style: const TextStyle(fontSize: 16), - textAlign: TextAlign.center, - ), - ], - ), - ); -} diff --git a/example/lib/src/screens/old/download/components/stats_table.dart b/example/lib/src/screens/old/download/components/stats_table.dart deleted file mode 100644 index 09d8bd12..00000000 --- a/example/lib/src/screens/old/download/components/stats_table.dart +++ /dev/null @@ -1,86 +0,0 @@ -part of '../download.dart'; - -class _StatsTable extends StatelessWidget { - const _StatsTable({ - required this.download, - }); - - final DownloadProgress? download; - - @override - Widget build(BuildContext context) => Table( - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [ - TableRow( - children: [ - StatDisplay( - statistic: - '${(download?.cachedTiles ?? 0) - (download?.bufferedTiles ?? 0)} + ${download?.bufferedTiles ?? 0}', - description: 'cached + buffered tiles', - ), - StatDisplay( - statistic: - '${(((download?.cachedSize ?? 0) - (download?.bufferedSize ?? 0)) * 1024).asReadableSize} + ${((download?.bufferedSize ?? 0) * 1024).asReadableSize}', - description: 'cached + buffered size', - ), - ], - ), - TableRow( - children: [ - StatDisplay( - statistic: - '${download?.skippedTiles ?? 0} (${(download?.skippedTiles ?? 0) == 0 ? 0 : (100 - (((download?.cachedTiles ?? 0) - (download?.skippedTiles ?? 0)) / (download?.cachedTiles ?? 0)) * 100).clamp(double.minPositive, 100).toStringAsFixed(1)}%)', - description: 'skipped tiles (% saving)', - ), - StatDisplay( - statistic: - '${((download?.skippedSize ?? 0) * 1024).asReadableSize} (${(download?.skippedTiles ?? 0) == 0 ? 0 : (100 - (((download?.cachedSize ?? 0) - (download?.skippedSize ?? 0)) / (download?.cachedSize ?? 0)) * 100).clamp(double.minPositive, 100).toStringAsFixed(1)}%)', - description: 'skipped size (% saving)', - ), - ], - ), - TableRow( - children: [ - RepaintBoundary( - child: Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - download?.failedTiles.toString() ?? '0', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: (download?.failedTiles ?? 0) == 0 - ? null - : Colors.red, - ), - ), - if ((download?.failedTiles ?? 0) != 0) ...[ - const SizedBox(width: 8), - const Icon( - Icons.warning_amber, - color: Colors.red, - ), - ], - ], - ), - Text( - 'failed tiles', - style: TextStyle( - fontSize: 16, - color: (download?.failedTiles ?? 0) == 0 - ? null - : Colors.red, - ), - ), - ], - ), - ), - const SizedBox.shrink(), - ], - ), - ], - ); -} diff --git a/example/lib/src/screens/old/download/download.dart b/example/lib/src/screens/old/download/download.dart deleted file mode 100644 index cafc78e5..00000000 --- a/example/lib/src/screens/old/download/download.dart +++ /dev/null @@ -1,343 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../../shared/misc/exts/size_formatter.dart'; -import 'components/confirm_cancellation_dialog.dart'; -import 'components/main_statistics.dart'; -import 'components/multi_linear_progress_indicator.dart'; -import 'components/stat_display.dart'; - -part 'components/stats_table.dart'; - -class DownloadPopup extends StatefulWidget { - const DownloadPopup({super.key}); - - static const String route = '/download/progress'; - - @override - State createState() => _DownloadPopupState(); -} - -class _DownloadPopupState extends State { - bool isInitialised = false; - - late final Stream downloadProgress; - late final StreamSubscription dpSubscription; - late final int maxTiles; - - final failedTiles = []; - final skippedTiles = []; - bool isCompleteCanPop = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - if (!isInitialised) { - final arguments = ModalRoute.of(context)!.settings.arguments! as ({ - Stream downloadProgress, - int maxTiles - }); - downloadProgress = arguments.downloadProgress.asBroadcastStream(); - dpSubscription = downloadProgress.listen((progress) { - if (progress.latestTileEvent.isRepeat) return; - if (progress.latestTileEvent.result.category == - TileEventResultCategory.failed) { - failedTiles.add(progress.latestTileEvent); - } - if (progress.latestTileEvent.result.category == - TileEventResultCategory.skipped) { - skippedTiles.add(progress.latestTileEvent); - } - isCompleteCanPop = progress.isComplete; - }); - maxTiles = arguments.maxTiles; - } - - isInitialised = true; - } - - @override - void dispose() { - dpSubscription.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => PopScope( - canPop: isCompleteCanPop, - onPopInvokedWithResult: (didPop, result) async { - if (!didPop && - await showDialog( - context: context, - builder: (context) => const ConfirmCancellationDialog(), - ) as bool && - context.mounted) Navigator.of(context).pop(); - }, - child: Scaffold( - appBar: AppBar( - title: const Text('Downloading Region'), - ), - body: Padding( - padding: const EdgeInsets.all(12), - child: StreamBuilder( - stream: downloadProgress, - builder: (context, snapshot) { - final download = snapshot.data; - - return LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth > 800; - - return SingleChildScrollView( - child: Column( - children: [ - IntrinsicHeight( - child: Flex( - direction: - isWide ? Axis.horizontal : Axis.vertical, - children: [ - Expanded( - child: Wrap( - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: - WrapCrossAlignment.center, - spacing: 32, - runSpacing: 28, - children: [ - RepaintBoundary( - child: SizedBox.square( - dimension: isWide ? 216 : 196, - child: ClipRRect( - borderRadius: - BorderRadius.circular(16), - child: download?.latestTileEvent - .tileImage != - null - ? Image.memory( - download!.latestTileEvent - .tileImage!, - gaplessPlayback: true, - ) - : const Center( - child: - CircularProgressIndicator - .adaptive(), - ), - ), - ), - ), - MainStatistics( - download: download, - maxTiles: maxTiles, - ), - ], - ), - ), - const SizedBox.square(dimension: 16), - if (isWide) - const VerticalDivider() - else - const Divider(), - const SizedBox.square(dimension: 16), - if (isWide) - Expanded( - child: _StatsTable(download: download), - ) - else - _StatsTable(download: download), - ], - ), - ), - const SizedBox(height: 30), - MulitLinearProgressIndicator( - maxValue: maxTiles, - backgroundChild: Text( - '${download?.remainingTiles ?? 0}', - style: const TextStyle(color: Colors.white), - ), - progresses: [ - ( - value: (download?.cachedTiles ?? 0) + - (download?.skippedTiles ?? 0) + - (download?.failedTiles ?? 0), - color: Colors.red, - child: Text( - '${download?.failedTiles ?? 0}', - style: const TextStyle(color: Colors.black), - ) - ), - ( - value: (download?.cachedTiles ?? 0) + - (download?.skippedTiles ?? 0), - color: Colors.yellow, - child: Text( - '${download?.skippedTiles ?? 0}', - style: const TextStyle(color: Colors.black), - ) - ), - ( - value: download?.cachedTiles ?? 0, - color: Colors.green[300]!, - child: Text( - '${download?.bufferedTiles ?? 0}', - style: const TextStyle(color: Colors.black), - ) - ), - ( - value: (download?.cachedTiles ?? 0) - - (download?.bufferedTiles ?? 0), - color: Colors.green, - child: Text( - '${(download?.cachedTiles ?? 0) - (download?.bufferedTiles ?? 0)}', - style: const TextStyle(color: Colors.black), - ) - ), - ], - ), - const SizedBox(height: 32), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const RotatedBox( - quarterTurns: 3, - child: Text( - 'FAILED TILES', - ), - ), - Expanded( - child: RepaintBoundary( - child: failedTiles.isEmpty - ? const Padding( - padding: EdgeInsets.symmetric( - horizontal: 2, - ), - child: Column( - children: [ - Icon(Icons.task_alt, size: 48), - SizedBox(height: 10), - Text( - 'Any failed tiles will appear here', - textAlign: TextAlign.center, - ), - ], - ), - ) - : ListView.builder( - reverse: true, - addRepaintBoundaries: false, - itemCount: failedTiles.length, - shrinkWrap: true, - itemBuilder: (context, index) => - ListTile( - leading: Icon( - switch ( - failedTiles[index].result) { - TileEventResult - .noConnectionDuringFetch => - Icons.wifi_off, - TileEventResult - .unknownFetchException => - Icons.error, - TileEventResult - .negativeFetchResponse => - Icons.reply, - _ => Icons.abc, - }, - ), - title: Text(failedTiles[index].url), - subtitle: Text( - switch ( - failedTiles[index].result) { - TileEventResult - .noConnectionDuringFetch => - 'Failed to establish a connection to the network', - TileEventResult - .unknownFetchException => - 'There was an unknown error when trying to download this tile, of type ${failedTiles[index].fetchError.runtimeType}', - TileEventResult - .negativeFetchResponse => - 'The tile server responded with an HTTP status code of ${failedTiles[index].fetchResponse!.statusCode} (${failedTiles[index].fetchResponse!.reasonPhrase})', - _ => throw Error(), - }, - ), - ), - ), - ), - ), - const SizedBox(width: 8), - const RotatedBox( - quarterTurns: 3, - child: Text( - 'SKIPPED TILES', - ), - ), - Expanded( - child: RepaintBoundary( - child: skippedTiles.isEmpty - ? const Padding( - padding: EdgeInsets.symmetric( - horizontal: 2, - ), - child: Column( - children: [ - Icon(Icons.task_alt, size: 48), - SizedBox(height: 10), - Text( - 'Any skipped tiles will appear here', - textAlign: TextAlign.center, - ), - ], - ), - ) - : ListView.builder( - reverse: true, - addRepaintBoundaries: false, - itemCount: skippedTiles.length, - shrinkWrap: true, - itemBuilder: (context, index) => - ListTile( - leading: Icon( - switch ( - skippedTiles[index].result) { - TileEventResult - .alreadyExisting => - Icons.disabled_visible, - TileEventResult.isSeaTile => - Icons.water_drop, - _ => Icons.abc, - }, - ), - title: - Text(skippedTiles[index].url), - subtitle: Text( - switch ( - skippedTiles[index].result) { - TileEventResult - .alreadyExisting => - 'Tile already exists', - TileEventResult.isSeaTile => - 'Tile is a sea tile', - _ => throw Error(), - }, - ), - ), - ), - ), - ), - ], - ), - ], - ), - ); - }, - ); - }, - ), - ), - ), - ); -} diff --git a/example/lib/src/shared/state/download_provider.dart b/example/lib/src/shared/state/download_provider.dart new file mode 100644 index 00000000..ba6e2d8c --- /dev/null +++ b/example/lib/src/shared/state/download_provider.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +class DownloadingProvider extends ChangeNotifier { + bool _isFocused = false; + bool get isFocused => _isFocused; + + bool _isPaused = false; + bool get isPaused => _isPaused; + + DownloadableRegion? _downloadableRegion; + DownloadableRegion get downloadableRegion => + _downloadableRegion ?? (throw _notReadyError); + + DownloadProgress? _latestEvent; + DownloadProgress get latestEvent => _latestEvent ?? (throw _notReadyError); + + late int _skippedSeaTileCount; + int get skippedSeaTileCount => _skippedSeaTileCount; + + late int _skippedSeaTileSize; + int get skippedSeaTileSize => _skippedSeaTileSize; + + late int _skippedExistingTileCount; + int get skippedExistingTileCount => _skippedExistingTileCount; + + late int _skippedExistingTileSize; + int get skippedExistingTileSize => _skippedExistingTileSize; + + late String _storeName; + late StreamSubscription _streamSub; + + void assignDownload({ + required String storeName, + required DownloadableRegion downloadableRegion, + required Stream stream, + }) { + _storeName = storeName; + _downloadableRegion = downloadableRegion; + + _skippedExistingTileCount = 0; + _skippedSeaTileCount = 0; + _skippedExistingTileSize = 0; + _skippedSeaTileSize = 0; + + _streamSub = stream.listen( + (progress) { + if (progress.attemptedTiles == 0) _isFocused = true; + _latestEvent = progress; + + final latestTile = progress.latestTileEvent; + + if (latestTile != null && !latestTile.isRepeat) { + if (latestTile.result == TileEventResult.alreadyExisting) { + _skippedExistingTileCount++; + _skippedExistingTileSize += latestTile.tileImage!.lengthInBytes; + } + if (latestTile.result == TileEventResult.isSeaTile) { + _skippedSeaTileCount++; + _skippedSeaTileSize += latestTile.tileImage!.lengthInBytes; + } + } + + notifyListeners(); + }, + onDone: () => _streamSub.cancel(), + ); + } + + Future pause() async { + await FMTCStore(_storeName).download.pause(); + _isPaused = true; + notifyListeners(); + } + + void resume() { + FMTCStore(_storeName).download.resume(); + _isPaused = false; + notifyListeners(); + } + + Future cancel() => FMTCStore(_storeName).download.cancel(); + + void reset() { + _isFocused = false; + notifyListeners(); + } + + StateError get _notReadyError => StateError( + 'Unsafe to retrieve information before a download has been assigned.', + ); +} diff --git a/lib/src/bulk_download/external/download_progress.dart b/lib/src/bulk_download/external/download_progress.dart index 54f0e15c..2fb6f16d 100644 --- a/lib/src/bulk_download/external/download_progress.dart +++ b/lib/src/bulk_download/external/download_progress.dart @@ -9,7 +9,7 @@ part of '../../../flutter_map_tile_caching.dart'; @immutable class DownloadProgress { const DownloadProgress.__({ - required TileEvent? latestTileEvent, + required this.latestTileEvent, required this.cachedTiles, required this.cachedSize, required this.bufferedTiles, @@ -22,7 +22,7 @@ class DownloadProgress { required this.tilesPerSecond, required this.isTPSArtificiallyCapped, required this.isComplete, - }) : _latestTileEvent = latestTileEvent; + }); factory DownloadProgress._initial({required int maxTiles}) => DownloadProgress.__( @@ -44,8 +44,7 @@ class DownloadProgress { /// The result of the latest attempted tile /// /// {@macro fmtc.tileevent.extraConsiderations} - TileEvent get latestTileEvent => _latestTileEvent!; - final TileEvent? _latestTileEvent; + final TileEvent? latestTileEvent; /// The number of new tiles successfully downloaded and in the tile buffer or /// cached @@ -201,7 +200,7 @@ class DownloadProgress { required int? rateLimit, }) => DownloadProgress.__( - latestTileEvent: latestTileEvent._copyWithRepeat(), + latestTileEvent: latestTileEvent?._copyWithRepeat(), cachedTiles: cachedTiles, cachedSize: cachedSize, bufferedTiles: bufferedTiles, @@ -228,7 +227,7 @@ class DownloadProgress { }) { final isNewTile = newTileEvent != null; return DownloadProgress.__( - latestTileEvent: newTileEvent ?? latestTileEvent._copyWithRepeat(), + latestTileEvent: newTileEvent, cachedTiles: isNewTile && newTileEvent.result.category == TileEventResultCategory.cached ? cachedTiles + 1 @@ -264,7 +263,7 @@ class DownloadProgress { bool operator ==(Object other) => identical(this, other) || (other is DownloadProgress && - _latestTileEvent == other._latestTileEvent && + latestTileEvent == other.latestTileEvent && cachedTiles == other.cachedTiles && cachedSize == other.cachedSize && bufferedTiles == other.bufferedTiles && @@ -280,7 +279,7 @@ class DownloadProgress { @override int get hashCode => Object.hashAllUnordered([ - _latestTileEvent, + latestTileEvent, cachedTiles, cachedSize, bufferedTiles, diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index 66246d53..610e2a37 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -216,6 +216,9 @@ Future _downloadManager( // Now it's safe, start accepting communications from the root send(rootReceivePort.sendPort); + // Send an initial progress report to indicate the start of the download + send(initialDownloadProgress); + // Start download threads & wait for download to complete/cancelled downloadDuration.start(); await Future.wait( diff --git a/lib/src/bulk_download/internal/tile_loops/count.dart b/lib/src/bulk_download/internal/tile_loops/count.dart index db20503a..77a71a04 100644 --- a/lib/src/bulk_download/internal/tile_loops/count.dart +++ b/lib/src/bulk_download/internal/tile_loops/count.dart @@ -6,10 +6,6 @@ part of 'shared.dart'; /// A set of methods for each type of [BaseRegion] that counts the number of /// tiles within the specified [DownloadableRegion] /// -/// Each method should handle a [DownloadableRegion] with a specific generic type -/// [BaseRegion]. If a method is passed a non-compatible region, it is expected -/// to throw a `CastError`. -/// /// These methods should be run within seperate isolates, as they do heavy, /// potentially lengthy computation. They do not perform multiple-communication, /// and so only require simple Isolate protocols such as [Isolate.run]. diff --git a/lib/src/bulk_download/internal/tile_loops/generate.dart b/lib/src/bulk_download/internal/tile_loops/generate.dart index 832e2f5f..9253cb68 100644 --- a/lib/src/bulk_download/internal/tile_loops/generate.dart +++ b/lib/src/bulk_download/internal/tile_loops/generate.dart @@ -6,10 +6,6 @@ part of 'shared.dart'; /// A set of methods for each type of [BaseRegion] that generates the coordinates /// of every tile within the specified [DownloadableRegion] /// -/// Each method should handle a [DownloadableRegion] with a specific generic type -/// [BaseRegion]. If a method is passed a non-compatible region, it is expected -/// to throw a `CastError`. -/// /// These methods must be run within seperate isolates, as they do heavy, /// potentially lengthy computation. They do perform multiple-communication, /// sending a new coordinate after they recieve a request message only. They will From 3d11ef7089b9d021d5429f3c56a29d79940dda60 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 21 Oct 2024 17:26:25 +0100 Subject: [PATCH 66/97] Improved example app --- .../components/greyscale_masker.dart | 21 ++--- .../download_progress_masker.dart | 17 ++-- .../download_configuration_view_side.dart | 8 +- .../components/tile_display/tile_display.dart | 69 ++++++++++++++++ .../components/title_bar/title_bar.dart | 70 +++++++++++++++++ .../components/statistics/statistics.dart | 78 +++---------------- .../downloading/downloading_view_side.dart | 8 +- .../layouts/side/components/panel.dart | 4 +- 8 files changed, 189 insertions(+), 86 deletions(-) create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/title_bar/title_bar.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 b714a4c6..b90ec63e 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 @@ -4,14 +4,14 @@ class GreyscaleMasker extends SingleChildRenderObjectWidget { const GreyscaleMasker({ super.key, required super.child, - required this.tileCoordinatesStream, + required this.latestTileCoordinates, required this.mapCamera, required this.minZoom, required this.maxZoom, required this.tileSize, }); - final Stream tileCoordinatesStream; + final TileCoordinates? latestTileCoordinates; final MapCamera mapCamera; final int minZoom; final int maxZoom; @@ -21,7 +21,6 @@ class GreyscaleMasker extends SingleChildRenderObjectWidget { RenderObject createRenderObject(BuildContext context) => _GreyscaleMaskerRenderer( mapCamera: mapCamera, - tileCoordinatesStream: tileCoordinatesStream, minZoom: minZoom, maxZoom: maxZoom, tileSize: tileSize, @@ -34,6 +33,7 @@ class GreyscaleMasker extends SingleChildRenderObjectWidget { _GreyscaleMaskerRenderer renderObject, ) { renderObject.mapCamera = mapCamera; + if (latestTileCoordinates case final ltc?) renderObject.addTile(ltc); // We don't support changing the other properties. They should not change // during a download. } @@ -41,7 +41,6 @@ class GreyscaleMasker extends SingleChildRenderObjectWidget { class _GreyscaleMaskerRenderer extends RenderProxyBox { _GreyscaleMaskerRenderer({ - required Stream tileCoordinatesStream, required MapCamera mapCamera, required this.minZoom, required this.maxZoom, @@ -60,9 +59,6 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { p++; } _maxSubtilesCountPerZoomLevel[p] = 0; - - // Handle incoming tile coordinates - tileCoordinatesStream.listen(_incomingTileHandler); } //! PROPERTIES @@ -76,6 +72,8 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { markNeedsPaint(); } + TileCoordinates? _prevTile; + /// Minimum zoom level of the download /// /// The difference of [maxZoom] & [minZoom] must be less than 32, due to @@ -233,10 +231,13 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /// not possible to prune the path cache, so this will slowly become /// out-of-sync and less efficient. See [_recompileGreyscalePathCache] /// for details. - void _incomingTileHandler(TileCoordinates tile) { + void addTile(TileCoordinates tile) { assert(tile.z >= minZoom, 'Incoming `tile` has zoom level below minimum'); assert(tile.z <= maxZoom, 'Incoming `tile` has zoom level above maximum'); + if (tile == _prevTile) return; + _prevTile = tile; + _recurseTileToMinZoomLevelParentWithCallback( tile, minZoom, @@ -337,8 +338,8 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /// greyscale, so we can save unnecessary painting steps. /// /// Therefore, it is likely more efficient to paint after running this method - /// than after a series of incoming tiles have been handled (as - /// [_incomingTileHandler] cannot prune the path cache, only the tile mapping). + /// than after a series of incoming tiles have been handled (as [addTile] + /// cannot prune the path cache, only the tile mapping). /// /// This method does not call [markNeedsPaint], the caller should perform that /// if necessary. 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 dfab651a..a0aaa428 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 @@ -36,18 +36,21 @@ class _DownloadProgressMaskerState extends State { Widget build(BuildContext context) { if (widget.downloadProgressStream case final dps?) { return RepaintBoundary( - child: GreyscaleMasker( - mapCamera: MapCamera.of(context), - tileCoordinatesStream: dps + child: StreamBuilder( + stream: dps .where( (e) => e.latestTileEvent != null && !e.latestTileEvent!.isRepeat, ) .map((e) => e.latestTileEvent!.coordinates), - minZoom: widget.minZoom, - maxZoom: widget.maxZoom, - tileSize: widget.tileSize, - child: widget.child, + builder: (context, snapshot) => GreyscaleMasker( + mapCamera: MapCamera.of(context), + latestTileCoordinates: snapshot.data, + minZoom: widget.minZoom, + maxZoom: widget.maxZoom, + tileSize: widget.tileSize, + child: widget.child, + ), ), ); } 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 06e1cd3a..9673e0ff 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 @@ -34,7 +34,13 @@ class DownloadConfigurationViewSide extends StatelessWidget { const SizedBox(height: 16), const Expanded( child: SideViewPanel( - child: SingleChildScrollView(child: ConfigOptions()), + autoPadding: false, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16), + child: ConfigOptions(), + ), + ), ), ), const SizedBox(height: 16), diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart new file mode 100644 index 00000000..1857a360 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../../../shared/state/download_provider.dart'; + +class TileDisplay extends StatelessWidget { + const TileDisplay({super.key}); + + static const _dimension = 180.0; + + @override + Widget build(BuildContext context) { + if (context + .watch() + .latestEvent + .latestTileEvent + ?.tileImage + case final tileImage?) { + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox.square( + dimension: _dimension, + child: Stack( + children: [ + Image.memory( + tileImage, + cacheHeight: _dimension.toInt(), + cacheWidth: _dimension.toInt(), + gaplessPlayback: true, + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + height: 32, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withAlpha(255 ~/ 2), + ), + child: const Center( + child: Text( + 'Latest tile', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + if (context.watch().latestEvent.isComplete) { + return const SizedBox.shrink(); + } + + return const Center( + child: SizedBox.square( + dimension: _dimension, + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + ); + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/title_bar/title_bar.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/title_bar/title_bar.dart new file mode 100644 index 00000000..780b1947 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/title_bar/title_bar.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../../../shared/state/download_provider.dart'; +import '../../../../../../../../../shared/state/region_selection_provider.dart'; + +class TitleBar extends StatelessWidget { + const TitleBar({super.key}); + + @override + Widget build(BuildContext context) { + if (context + .select((p) => p.latestEvent.isComplete)) { + return IntrinsicHeight( + child: Row( + children: [ + Text( + 'Downloading complete', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Spacer(), + SizedBox( + height: double.infinity, + child: FilledButton.icon( + onPressed: () { + context.read() + ..isDownloadSetupPanelVisible = false + ..clearConstructedRegions() + ..clearCoordinates(); + context.read().reset(); + }, + label: const Text('Reset'), + icon: const Icon(Icons.done_all), + ), + ), + ], + ), + ); + } + if (context.select((p) => p.isPaused)) { + return Row( + children: [ + Text( + 'Downloading paused', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Spacer(), + const Icon(Icons.pause_circle, size: 36), + ], + ); + } else { + return Row( + children: [ + Text( + 'Downloading map', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Spacer(), + const Padding( + padding: EdgeInsets.all(2), + child: SizedBox.square( + dimension: 32, + child: CircularProgressIndicator.adaptive(), + ), + ), + ], + ); + } + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart index 9bbadcb1..08036662 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart @@ -1,81 +1,27 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../../../../../../../shared/state/download_provider.dart'; -import '../../../../../../../shared/state/region_selection_provider.dart'; import 'components/progress/indicator_bars.dart'; import 'components/progress/indicator_text.dart'; +import 'components/tile_display/tile_display.dart'; import 'components/timing/timing.dart'; +import 'components/title_bar/title_bar.dart'; class DownloadStatistics extends StatelessWidget { const DownloadStatistics({super.key}); @override - Widget build(BuildContext context) => Column( + Widget build(BuildContext context) => const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (context.select( - (p) => p.latestEvent.isComplete, - )) - IntrinsicHeight( - child: Row( - children: [ - Text( - 'Downloading complete', - style: Theme.of(context).textTheme.headlineMedium, - ), - const Spacer(), - SizedBox( - height: double.infinity, - child: FilledButton.icon( - onPressed: () { - context.read() - ..isDownloadSetupPanelVisible = false - ..clearConstructedRegions() - ..clearCoordinates(); - context.read().reset(); - }, - label: const Text('Reset'), - icon: const Icon(Icons.done_all), - ), - ), - ], - ), - ) - else if (context.select((p) => p.isPaused)) - Row( - children: [ - Text( - 'Downloading paused', - style: Theme.of(context).textTheme.headlineMedium, - ), - const Spacer(), - const Icon(Icons.pause_circle, size: 36), - ], - ) - else - Row( - children: [ - Text( - 'Downloading map', - style: Theme.of(context).textTheme.headlineMedium, - ), - const Spacer(), - const Padding( - padding: EdgeInsets.all(2), - child: SizedBox.square( - dimension: 32, - child: CircularProgressIndicator.adaptive(), - ), - ), - ], - ), - const SizedBox(height: 24), - const TimingStats(), - const SizedBox(height: 24), - const ProgressIndicatorBars(), - const SizedBox(height: 16), - const ProgressIndicatorText(), + TitleBar(), + SizedBox(height: 24), + TimingStats(), + SizedBox(height: 24), + ProgressIndicatorBars(), + SizedBox(height: 16), + ProgressIndicatorText(), + SizedBox(height: 24), + TileDisplay(), ], ); } diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart index 976cd5c3..f2050675 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart @@ -101,7 +101,13 @@ class _DownloadingViewSideState extends State { const SizedBox(height: 16), const Expanded( child: SideViewPanel( - child: SingleChildScrollView(child: DownloadStatistics()), + autoPadding: false, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16), + child: DownloadStatistics(), + ), + ), ), ), const SizedBox(height: 16), diff --git a/example/lib/src/screens/main/secondary_view/layouts/side/components/panel.dart b/example/lib/src/screens/main/secondary_view/layouts/side/components/panel.dart index d6dd76b6..9717f977 100644 --- a/example/lib/src/screens/main/secondary_view/layouts/side/components/panel.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/side/components/panel.dart @@ -4,9 +4,11 @@ class SideViewPanel extends StatelessWidget { const SideViewPanel({ super.key, required this.child, + this.autoPadding = true, }); final Widget child; + final bool autoPadding; @override Widget build(BuildContext context) => Container( @@ -14,7 +16,7 @@ class SideViewPanel extends StatelessWidget { borderRadius: BorderRadius.circular(16), color: Theme.of(context).colorScheme.surface, ), - padding: const EdgeInsets.all(16), + padding: autoPadding ? const EdgeInsets.all(16) : null, width: double.infinity, child: child, ); From 17f01c00112ea94c6d1f070ed949b19229f4f4cf Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 24 Oct 2024 22:45:51 +0100 Subject: [PATCH 67/97] Prepared for prerelease -dev.6 Added `StackTrace` output to `TileLoadingInterceptorResult.error` (converted existing type to Record) Improved documentation Updated CHANGELOG --- CHANGELOG.md | 16 ++++++---- example/analysis_options.yaml | 4 --- .../debugging_tile_builder/info_display.dart | 2 +- lib/src/backend/backend_access.dart | 3 +- .../impls/objectbox/backend/internal.dart | 1 - .../external/download_progress.dart | 15 +++++++++- .../image_provider/image_provider.dart | 8 ++--- .../image_provider/internal_get_bytes.dart | 4 +-- .../tile_loading_interceptor/result.dart | 4 +-- .../tile_provider/tile_provider.dart | 2 +- lib/src/regions/base_region.dart | 2 ++ lib/src/regions/shapes/multi.dart | 21 +++++++++++-- lib/src/store/download.dart | 30 +++++++++++-------- pubspec.yaml | 10 +++---- 14 files changed, 79 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef5eb6e..85f175ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ This allows a new paradigm to be used: stores may now be treated as bulk downloa Additionally, vector tiles are now supported in theory, as the internal caching/retrieval logic of the specialised `ImageProvider` has been exposed, although it is out of scope to fully implement support for it. * Improvements to the browse caching logic and customizability - * Added support for using multiple stores simultaneously in the `FMTCTileProvider` (through `FMTCTileProvider.allStores` & `FMTCTileProvider.multipleStores` constructors) + * Added support for using multiple stores simultaneously in the `FMTCTileProvider` (through the `FMTCTileProvider.allStores` & `FMTCTileProvider.multipleStores` constructors) * Added `FMTCTileProvider.getBytes` method to expose internal caching mechanisms for external use * Added `BrowseStoreStrategy` for increased control over caching behaviour * Added 'tile loading interceptor' feature (`FMTCTileProvider.tileLoadingInterceptor`) to track (eg. for debugging and logging) the internal tile loading mechanisms @@ -36,21 +36,25 @@ Additionally, vector tiles are now supported in theory, as the internal caching/ * Replaced `FMTCBrowsingErrorHandler` with `BrowsingExceptionHandler`, which may now return bytes to be displayed instead of (re)throwing exception * Replaced `obscureQueryParams` with more flexible `urlTransformer` (and static `FMTCTileProvider.urlTransformerOmitKeyValues` utility method to provide old behaviour with more customizability) - also applies to bulk downloading in `StoreDownload.startForeground` * Removed `FMTCTileProviderSettings` & absorbed properties directly into `FMTCTileProvider` + * Performance of the internal tile image provider has been significantly improved when fetching images from the network URL + > There was a significant time loss due to attempting to handle the network request response as a stream of incoming bytes, which allowed for `chunkEvents` to be reported back to Flutter (allowing it to get progress updates on the state of the tile), but meant the bytes had to be collected and built manually. Removing this functionality allows the network requests to use more streamlined 'package:http' methods, which does not expose a stream of incoming bytes, meaning that bytes no longer have to be treated manually. This can save hundreds of milliseconds on tile loading - a significant time save of potentially up to ~50% in some cases! -* Improvements & additions to bulk downloadable `BaseRegion`s +* Improvements, additions, and removals for bulk downloadable `BaseRegion`s * Added `MultiRegion`, which contains multiple other `BaseRegion`s * Improved speed (by massive amounts) and accuracy & reduced memory consumption of `CircleRegion`'s tile generation & counting algorithm + * Deprecated `BaseRegion.(maybe)When` - this is easy to perform using a standard pattern-matched switch -And here's some smaller, but still remarkable, changes: +* Changes to bulk downloading + * `DownloadProgress.latestTileEvent` is now nullable -* Performance of the internal tile image provider has been significantly improved when fetching images from the network URL - There was a significant time loss due to attempting to handle the network request response as a stream of incoming bytes, which allowed for `chunkEvents` to be reported back to Flutter (allowing it to get progress updates on the state of the tile), but meant the bytes had to be collected and built manually. Removing this functionality allows the network requests to use more streamlined 'package:http' methods, which does not expose a stream of incoming bytes, meaning that bytes no longer have to be treated manually. This can save hundreds of milliseconds on tile loading - a significant time save of potentially up to ~50% in some cases! * Exporting stores is now more stable, and has improved documentation The method now works in a dedicated temporary environment and attempts to perform two different strategies to move/copy-and-delete the result to the specified directory at the end before failing. Improved documentation covers the potential pitfalls of permissions and now recommends exporting to an app directory, then using the system share functionality on some devices. It now also returns the number of exported tiles. * Removed deprecated remnants from v9.* -* Other generic improvements (performance & stability) +* Other generic improvements (performance, stability, and documentation) + +* Brand new example app to (partially!) showcase the new levels of flexibility and customizability ## [9.1.3] - 2024/08/19 diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 011223fb..cb1978b3 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1,9 +1,5 @@ include: ../analysis_options.yaml -analyzer: - exclude: - - old_lib/ - linter: rules: public_member_api_docs: false diff --git a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart index 9a46d5ae..2ed756a0 100644 --- a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart +++ b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart @@ -23,7 +23,7 @@ class _ResultDisplay extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), - if (fmtcResult.error case final error?) + if (fmtcResult.error?.error case final error?) Text( error is FMTCBrowsingError ? '`${error.type.name}`' diff --git a/lib/src/backend/backend_access.dart b/lib/src/backend/backend_access.dart index 371d0757..339dd56b 100644 --- a/lib/src/backend/backend_access.dart +++ b/lib/src/backend/backend_access.dart @@ -50,7 +50,8 @@ abstract mixin class FMTCBackendAccessThreadSafe { static FMTCBackendInternalThreadSafe? _internal; /// Provides access to the thread-seperate backend internals - /// ([FMTCBackendInternalThreadSafe]) globally with some level of access control + /// ([FMTCBackendInternalThreadSafe]) globally with some level of access + /// control /// /// {@macro fmtc.backend.access} @meta.internal diff --git a/lib/src/backend/impls/objectbox/backend/internal.dart b/lib/src/backend/impls/objectbox/backend/internal.dart index b126bbbb..f7b90b14 100644 --- a/lib/src/backend/impls/objectbox/backend/internal.dart +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -90,7 +90,6 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { // Efficienctly forward resulting stream, but add extra debug info to any // errors - // TODO: verify yield* controller.stream.handleError( (err, stackTrace) => Error.throwWithStackTrace( err, diff --git a/lib/src/bulk_download/external/download_progress.dart b/lib/src/bulk_download/external/download_progress.dart index 2fb6f16d..54ca3d72 100644 --- a/lib/src/bulk_download/external/download_progress.dart +++ b/lib/src/bulk_download/external/download_progress.dart @@ -41,7 +41,20 @@ class DownloadProgress { isComplete: false, ); - /// The result of the latest attempted tile + /// The result of the latest attempted tile, if applicable + /// + /// `null` if the first tile has not yet been downloaded, or the download has + /// completed. The completion of a download may be considered to be reported + /// twice, although only the one with [isComplete] should be taken as the + /// final indicator: the last tile may (or may not) make all the statistics + /// 100%, but it will not set [isComplete]. An event is emitted after the + /// final tile, with no tile set, but [isComplete] set. + /// + /// A similar [TileEvent] may be emitted multiple times sequentially, due to + /// fallback reporting (configurable in [StoreDownload.startForeground]). In + /// this event, this is not set `null`, but [TileEvent.isRepeat] will be set. + /// If this flag is set, the tile should not be counted as a new event in any + /// listener. /// /// {@macro fmtc.tileevent.extraConsiderations} final TileEvent? latestTileEvent; diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index 6b67557a..d9200554 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -69,7 +69,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { }, ); - /// {@macro fmtc.imageProvider.getBytes} + /// {@macro fmtc.tileProvider.getBytes} static Future getBytes({ required TileCoordinates coords, required TileLayer options, @@ -82,7 +82,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { final currentTLIR = provider.tileLoadingInterceptor != null ? _TLIRConstructor._() : null; - void close([Object? error]) { + void close([({Object error, StackTrace stackTrace})? error]) { finishedLoadingBytes?.call(); if (key != null && error != null) { @@ -126,14 +126,14 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { currentTLIR: currentTLIR, ); } catch (err, stackTrace) { - close(err); + close((error: err, stackTrace: stackTrace)); if (err is FMTCBrowsingError) { final handlerResult = provider.errorHandler?.call(err); if (handlerResult != null) return handlerResult; } - Error.throwWithStackTrace(err, stackTrace); + rethrow; } close(); diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index 31a02552..2f096ddb 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -22,7 +22,7 @@ Future _internalGetBytes({ currentTLIR?.hitOrMiss = false; if (provider.recordHitsAndMisses) { FMTCBackendAccess.internal.registerHitOrMiss( - storeNames: provider._getSpecifiedStoresOrNull(), // TODO: Verify + storeNames: provider._getSpecifiedStoresOrNull(), hit: false, ); } @@ -248,7 +248,7 @@ Future _internalGetBytes({ if (createdIn.isEmpty) return; FMTCBackendAccess.internal.removeOldestTilesAboveLimit( - storeNames: createdIn.toList(growable: false), // TODO: Verify + storeNames: createdIn.toList(growable: false), ); }); } diff --git a/lib/src/providers/tile_loading_interceptor/result.dart b/lib/src/providers/tile_loading_interceptor/result.dart index 07f85e0e..78d42be0 100644 --- a/lib/src/providers/tile_loading_interceptor/result.dart +++ b/lib/src/providers/tile_loading_interceptor/result.dart @@ -11,7 +11,7 @@ class _TLIRConstructor { _TLIRConstructor._(); TileLoadingInterceptorResultPath? resultPath; - Object? error; + ({Object error, StackTrace stackTrace})? error; late String networkUrl; late String storageSuitableUID; List? existingStores; @@ -59,7 +59,7 @@ class TileLoadingInterceptorResult { /// /// See [didComplete] for a boolean result. If `null`, see [resultPath] for the /// exact result path. - final Object? error; + final ({Object error, StackTrace stackTrace})? error; /// Indicates whether the tile completed loading successfully /// diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index dab0eede..8c655156 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -252,7 +252,7 @@ class FMTCTileProvider extends TileProvider { super.dispose(); } - /// {@template fmtc.imageProvider.getBytes} + /// {@template fmtc.tileProvider.getBytes} /// Use FMTC's caching logic to get the bytes of the specific tile (at /// [coords]) with the specified [TileLayer] options and [FMTCTileProvider] /// provider diff --git a/lib/src/regions/base_region.dart b/lib/src/regions/base_region.dart index 236cedba..2b309af3 100644 --- a/lib/src/regions/base_region.dart +++ b/lib/src/regions/base_region.dart @@ -90,6 +90,8 @@ sealed class BaseRegion { /// Generate the list of all the [LatLng]s forming the outline of this region /// + /// May not be supported on all region implementations. + /// /// Returns a `Iterable` which can be used anywhere. Iterable toOutline(); diff --git a/lib/src/regions/shapes/multi.dart b/lib/src/regions/shapes/multi.dart index c4f3de77..2f2c143b 100644 --- a/lib/src/regions/shapes/multi.dart +++ b/lib/src/regions/shapes/multi.dart @@ -6,8 +6,25 @@ part of '../../../../flutter_map_tile_caching.dart'; /// A region formed from multiple other [BaseRegion]s /// /// When downloading, each sub-region specified in [regions] is downloaded -/// consecutively. Overlaps are not resolved into single regions, so it is -/// recommended to enable `skipExistingTiles` in [StoreDownload.startForeground]. +/// consecutively. The advantage of [MultiRegion] is that: +/// +/// * it avoids repeating the expensive setup and teardown of a bulk download +/// between each sub-region +/// * the progress of the download is reported as a whole, so no additional +/// work is required to keep track of which download is currently being +/// performed and keep track of custom progress statistics +/// +/// Overlaps and intersections are not (yet) compiled into single +/// [CustomPolygonRegion]s. Therefore, where regions are known to overlap: +/// +/// * (particularly where regions are [RectangleRegion]s & [CustomPolygonRegion]s) +/// Use ['package:polybool'](https://pub.dev/packages/polybool) (a 3rd party +/// package in no way associated with FMTC) to take the `union` all polygons: +/// this will remove self-intersections, combine overlapping polygons into +/// single polygons, etc - this is best for efficiency. +/// +/// * (particularly where multiple different other region types are used) +/// Enable `skipExistingTiles` in [StoreDownload.startForeground]. /// /// [MultiRegion]s may be nested. /// diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index f4058a0b..1b861013 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -35,12 +35,19 @@ class StoreDownload { /// recovery session by default /// /// > [!TIP] - /// > To check the number of tiles in a region before starting a download, use + /// > To count the number of tiles in a region before starting a download, use /// > [check]. /// /// Streams a [DownloadProgress] object containing statistics and information /// about the download's progression status, once per tile and at intervals - /// of no longer than [maxReportInterval] (after the first tile). + /// of no longer than [maxReportInterval]. + /// + /// The first event reports the download has started (setup is complete). The + /// last event indicates the download has completed (either sucessfully or + /// been cancelled). All events between these two emissions will be reporting + /// a new tile, or reporting the old tile, due to [maxReportInterval]. + /// See the documentation on [DownloadProgress.latestTileEvent] & + /// [TileEvent.isRepeat] for more details. /// /// --- /// @@ -128,17 +135,17 @@ class StoreDownload { UrlTransformer? urlTransformer, Object instanceId = 0, }) async* { - FMTCBackendAccess.internal; // Verify intialisation + FMTCBackendAccess.internal; // Verify initialisation // Check input arguments for suitability if (!(region.options.wmsOptions != null || region.options.urlTemplate != null)) { throw ArgumentError( - "`.toDownloadable`'s `TileLayer` argument must specify an appropriate `urlTemplate` or `wmsOptions`", + "`.toDownloadable`'s `TileLayer` argument must specify an appropriate " + '`urlTemplate` or `wmsOptions`', 'region.options.urlTemplate', ); } - if (parallelThreads < 1) { throw ArgumentError.value( parallelThreads, @@ -146,7 +153,6 @@ class StoreDownload { 'must be 1 or greater', ); } - if (maxBufferLength < 0) { throw ArgumentError.value( maxBufferLength, @@ -154,7 +160,6 @@ class StoreDownload { 'must be 0 or greater', ); } - if ((rateLimit ?? 2) < 1) { throw ArgumentError.value( rateLimit, @@ -263,14 +268,13 @@ class StoreDownload { cancelCompleter.complete(); } - /// Check how many downloadable tiles are within a specified region - /// - /// This does not include skipped sea tiles or skipped existing tiles, as those - /// are handled during download only. + /// Count the number of tiles within the specified region /// - /// Note that this does not require a valid/ready/existing store. + /// This does not include skipped sea tiles or skipped existing tiles, as + /// those are handled during a download (as the contents must be known). /// - /// Returns the number of tiles. + /// Note that this does not require an existing/ready store, or a sensical + /// [DownloadableRegion.options]. Future check(DownloadableRegion region) => compute( (region) => region.when( rectangle: TileCounters.rectangleTiles, diff --git a/pubspec.yaml b/pubspec.yaml index b71241ec..c5b4dcbd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 10.0.0-dev.5 +version: 10.0.0-dev.6 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues @@ -37,14 +37,14 @@ dependencies: http: ^1.2.2 latlong2: ^0.9.1 meta: ^1.15.0 - objectbox: ^4.0.2 - objectbox_flutter_libs: ^4.0.2 + objectbox: ^4.0.3 + objectbox_flutter_libs: ^4.0.3 path: ^1.9.0 path_provider: ^2.1.4 dev_dependencies: - build_runner: ^2.4.12 - objectbox_generator: ^4.0.2 + build_runner: ^2.4.13 + objectbox_generator: ^4.0.3 test: ^1.25.8 flutter: null From 8a183908a6b7d57e10d856f0262e44f1344bad63 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 27 Oct 2024 22:01:29 +0000 Subject: [PATCH 68/97] Improved example app --- .../debugging_tile_builder.dart | 39 +-- .../debugging_tile_builder/info_display.dart | 68 +++--- .../components/greyscale_masker.dart | 26 +- .../components/fmtc_not_in_use_indicator.dart | 50 ++++ .../src/screens/main/map_view/map_view.dart | 224 ++++++------------ .../confirmation_panel.dart | 18 +- .../components/no_sub_regions.dart | 12 +- .../src/shared/state/download_provider.dart | 7 + .../tile_provider/tile_provider.dart | 6 +- 9 files changed, 200 insertions(+), 250 deletions(-) create mode 100644 example/lib/src/screens/main/map_view/components/fmtc_not_in_use_indicator.dart diff --git a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart index ebe8163f..87a3013d 100644 --- a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart +++ b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart @@ -11,13 +11,11 @@ class DebuggingTileBuilder extends StatefulWidget { required this.tileWidget, required this.tile, required this.tileLoadingDebugger, - required this.usingFMTC, }); final Widget tileWidget; final TileImage tile; final ValueNotifier tileLoadingDebugger; - final bool usingFMTC; @override State createState() => _DebuggingTileBuilderState(); @@ -40,33 +38,18 @@ class _DebuggingTileBuilderState extends State { position: DecorationPosition.foreground, child: widget.tileWidget, ), - if (!widget.usingFMTC) - const OverflowBox( - child: Padding( - padding: EdgeInsets.all(6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.file_download_off, size: 32), - SizedBox(height: 6), - Text('FMTC not in use'), - ], - ), - ), - ) - else - ValueListenableBuilder( - valueListenable: widget.tileLoadingDebugger, - builder: (context, value, _) { - if (value[widget.tile.coordinates] case final info?) { - return _ResultDisplay(tile: widget.tile, fmtcResult: info); - } + ValueListenableBuilder( + valueListenable: widget.tileLoadingDebugger, + builder: (context, value, _) { + if (value[widget.tile.coordinates] case final info?) { + return _ResultDisplay(tile: widget.tile, fmtcResult: info); + } - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - }, - ), + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + }, + ), ], ); } diff --git a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart index 2ed756a0..002b8309 100644 --- a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart +++ b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart @@ -45,44 +45,44 @@ class _ResultDisplay extends StatelessWidget { ), Row( children: [ - IconButton.filledTonal( - onPressed: fmtcResult.existingStores != null - ? () { - showDialog( - context: context, - builder: (context) => _TileReadResultsDialog( - results: fmtcResult.existingStores!, - trfosaf: fmtcResult - .tileRetrievedFromOtherStoresAsFallback, - ), - ); - } - : null, - icon: fmtcResult.existingStores != null - ? const Icon(Icons.visibility) - : const Icon(Icons.visibility_off), - tooltip: 'View cache exists result', + GestureDetector( + onTap: () { + if (fmtcResult.existingStores == null) return; + showDialog( + context: context, + builder: (context) => _TileReadResultsDialog( + results: fmtcResult.existingStores!, + trfosaf: fmtcResult + .tileRetrievedFromOtherStoresAsFallback, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: fmtcResult.existingStores != null + ? const Icon(Icons.visibility) + : const Icon(Icons.visibility_off), + ), ), const SizedBox(width: 8), FutureBuilder( - // TODO: Factor out of build to reduce re-futures - // or fix rebuild issue future: fmtcResult.storesWriteResult, - builder: (context, snapshot) => IconButton.filledTonal( - onPressed: snapshot.data != null - ? () { - showDialog( - context: context, - builder: (context) => _TileWriteResultsDialog( - results: snapshot.data!, - ), - ); - } - : null, - icon: snapshot.data != null - ? const Icon(Icons.edit) - : const Icon(Icons.edit_off), - tooltip: 'View write result', + builder: (context, snapshot) => GestureDetector( + onTap: () { + if (snapshot.data == null) return; + showDialog( + context: context, + builder: (context) => _TileWriteResultsDialog( + results: snapshot.data!, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: snapshot.data != null + ? const Icon(Icons.edit) + : const Icon(Icons.edit_off), + ), ), ), ], 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 b90ec63e..4b72d7e9 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 @@ -91,9 +91,6 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { //! STATE - /// Stream subscription for input `tileCoordinates` stream - late final StreamSubscription _tileCoordinatesSub; - /// Maps tiles of a download to a [_TileMappingValue], which contains: /// * the number of subtiles downloaded /// * the lat/lng coordinates of the tile's top-left (North-West) & @@ -107,8 +104,8 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /// root tile should increment the value. With the exception of this case, the /// existence of a tile key is an indication that that parent tile has been /// downloaded. - final Map _tileMapping = SplayTreeMap( - (a, b) => a.z.compareTo(b.z) | a.x.compareTo(b.x) | a.y.compareTo(b.y), + final _tileMapping = SplayTreeMap( + (b, a) => a.z.compareTo(b.z) | a.x.compareTo(b.x) | a.y.compareTo(b.y), ); /// The number of subtiles a tile at the zoom level (index) may have @@ -128,12 +125,6 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { }); static const _greyscaleLevelsCount = 25; - @override - void dispose() { - _tileCoordinatesSub.cancel(); - super.dispose(); - } - //! GREYSCALE HANDLING /// Calculate the grayscale color filter given a percentage @@ -348,14 +339,15 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { path.reset(); } - for (int i = _tileMapping.length - 1; i >= 0; i--) { - final MapEntry(key: tile, value: tmv) = _tileMapping.entries.elementAt(i); - + for (final MapEntry( + key: TileCoordinates(z: tileZoom), + value: _TileMappingValue(:subtilesCount, :nwCoord, :seCoord), + ) in _tileMapping.entries) { _greyscalePathCache[_calculateGreyscaleLevel( - tmv.subtilesCount, - _maxSubtilesCountPerZoomLevel[tile.z - minZoom], + subtilesCount, + _maxSubtilesCountPerZoomLevel[tileZoom - minZoom], )]! - .addRect(_calculateRectOfCoords(tmv.nwCoord, tmv.seCoord)); + .addRect(_calculateRectOfCoords(nwCoord, seCoord)); } } diff --git a/example/lib/src/screens/main/map_view/components/fmtc_not_in_use_indicator.dart b/example/lib/src/screens/main/map_view/components/fmtc_not_in_use_indicator.dart new file mode 100644 index 00000000..0c311037 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/fmtc_not_in_use_indicator.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import '../map_view.dart'; + +class FMTCNotInUseIndicator extends StatelessWidget { + const FMTCNotInUseIndicator({ + super.key, + required this.mode, + }); + + final MapViewMode mode; + + @override + Widget build(BuildContext context) => Opacity( + opacity: 0.8, + child: IgnorePointer( + child: AnimatedSlide( + offset: mode != MapViewMode.standard + ? Offset.zero + : const Offset(1.1, 0), + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: FittedBox( + child: Container( + decoration: BoxDecoration( + color: Colors.amber, + borderRadius: BorderRadius.circular(99), + boxShadow: [ + BoxShadow( + color: Colors.grey.withAlpha(255 ~/ 2), + spreadRadius: 6, + blurRadius: 8, + ), + ], + ), + padding: + const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: const Row( + children: [ + Icon(Icons.hide_image), + SizedBox(width: 8), + Text('FMTC not in use in this view'), + ], + ), + ), + ), + ), + ), + ); +} 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 60feb9ea..2613c6e2 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -13,10 +13,12 @@ import 'package:provider/provider.dart'; import '../../../shared/misc/shared_preferences.dart'; import '../../../shared/misc/store_metadata_keys.dart'; +import '../../../shared/state/download_provider.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/download_progress_masker.dart'; +import 'components/fmtc_not_in_use_indicator.dart'; import 'components/region_selection/crosshairs.dart'; import 'components/region_selection/custom_polygon_snapping_indicator.dart'; import 'components/region_selection/region_shape.dart'; @@ -65,71 +67,23 @@ class _MapViewState extends State with TickerProviderStateMixin { }, ).distinct(mapEquals); - /*final _testingCoordsList = [ - //TileCoordinates(2212, 1468, 12), - //TileCoordinates(2212 * 2, 1468 * 2, 13), - //TileCoordinates(2212 * 2 * 2, 1468 * 2 * 2, 14), - //TileCoordinates(2212 * 2 * 2 * 2, 1468 * 2 * 2 * 2, 15), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2, - 1468 * 2 * 2 * 2 * 2, - 16, - ), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2 * 2, - 1468 * 2 * 2 * 2 * 2 * 2, - 17, - ), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2 * 2 * 2, - 1468 * 2 * 2 * 2 * 2 * 2 * 2, - 18, - ), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2 * 2 * 2 + 1, - 1468 * 2 * 2 * 2 * 2 * 2 * 2 + 1, - 18, - ), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2 * 2 * 2, - 1468 * 2 * 2 * 2 * 2 * 2 * 2 + 1, - 18, - ), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2, - 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2, - 19, - ), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 1, - 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2, - 19, - ), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2, - 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 1, - 19, - ), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 1, - 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 1, - 19, - ), - const TileCoordinates( - 2212 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 2, - 1468 * 2 * 2 * 2 * 2 * 2 * 2 * 2 + 2, - 19, - ), - ];*/ - - Stream? _testingDownloadTileCoordsStream; - bool _isInRegionSelectMode() => widget.mode == MapViewMode.downloadRegion && !context.read().isDownloadSetupPanelVisible; @override Widget build(BuildContext context) { + final isCrosshairsVisible = widget.mode == MapViewMode.downloadRegion && + !context.select( + (p) => p.isDownloadSetupPanelVisible, + ) && + context.select( + (p) => p.regionSelectionMethod, + ) == + RegionSelectionMethod.useMapCenter && + !context + .select((p) => p.customPolygonSnap); + final mapOptions = MapOptions( initialCenter: LatLng( sharedPrefs.getDouble(SharedPrefsKeys.mapLocationLat.name) ?? 51.5216, @@ -146,34 +100,7 @@ class _MapViewState extends State with TickerProviderStateMixin { keepAlive: true, backgroundColor: const Color(0xFFaad3df), onTap: (_, __) { - if (!_isInRegionSelectMode()) { - setState( - () => _testingDownloadTileCoordsStream = - const FMTCStore('Local Tile Server').download.startForeground( - region: const CircleRegion( - LatLng(45.3052535669648, 14.476223064038985), - 5, - ).toDownloadable( - minZoom: 5, - maxZoom: 15, - options: TileLayer( - urlTemplate: 'http://0.0.0.0:7070/{z}/{x}/{y}', - //urlTemplate: - // 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - ), - ), - parallelThreads: 3, - skipSeaTiles: false, - urlTransformer: (url) => - FMTCTileProvider.urlTransformerOmitKeyValues( - url: url, - keys: ['access_token'], - ), - rateLimit: 20, - ), - ); - return; - } + if (!_isInRegionSelectMode()) return; final provider = context.read(); @@ -217,7 +144,6 @@ class _MapViewState extends State with TickerProviderStateMixin { } }, onSecondaryTap: (_, __) { - const FMTCStore('Local Tile Server').download.cancel(); if (!_isInRegionSelectMode()) return; context.read().removeLastCoordinate(); }, @@ -379,72 +305,69 @@ class _MapViewState extends State with TickerProviderStateMixin { urlTemplate: urlTemplate, userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', maxNativeZoom: 20, - tileProvider: - compiledStoreNames.isEmpty && otherStoresStrategy == null - ? NetworkTileProvider() - : FMTCTileProvider.multipleStores( - storeNames: compiledStoreNames, - otherStoresStrategy: otherStoresStrategy, - loadingStrategy: provider.loadingStrategy, - useOtherStoresAsFallbackOnly: - provider.useUnspecifiedAsFallbackOnly, - recordHitsAndMisses: false, - tileLoadingInterceptor: _tileLoadingDebugger, - httpClient: _httpClient, - // ignore: invalid_use_of_visible_for_testing_member - fakeNetworkDisconnect: provider.fakeNetworkDisconnect, - ), - tileBuilder: !provider.displayDebugOverlay + tileProvider: widget.mode != MapViewMode.standard + ? NetworkTileProvider() + : FMTCTileProvider.multipleStores( + storeNames: compiledStoreNames, + otherStoresStrategy: otherStoresStrategy, + loadingStrategy: provider.loadingStrategy, + useOtherStoresAsFallbackOnly: + provider.useUnspecifiedAsFallbackOnly, + recordHitsAndMisses: false, + tileLoadingInterceptor: _tileLoadingDebugger, + httpClient: _httpClient, + // ignore: invalid_use_of_visible_for_testing_member + fakeNetworkDisconnect: provider.fakeNetworkDisconnect, + ), + tileBuilder: !provider.displayDebugOverlay || + widget.mode != MapViewMode.standard ? null : (context, tileWidget, tile) => DebuggingTileBuilder( tileLoadingDebugger: _tileLoadingDebugger, tileWidget: tileWidget, tile: tile, - usingFMTC: compiledStoreNames.isNotEmpty || - otherStoresStrategy != null, ), ); + final isDownloadProgressMaskerVisible = widget.mode == + MapViewMode.downloadRegion && + context.select((p) => p.isFocused); + final map = FlutterMap( mapController: _mapController.mapController, options: mapOptions, children: [ DownloadProgressMasker( - downloadProgressStream: _testingDownloadTileCoordsStream, - minZoom: 5, - maxZoom: 15, + key: ObjectKey( + isDownloadProgressMaskerVisible + ? context + .select( + (p) => p.downloadableRegion, + ) + : null, + ), + downloadProgressStream: isDownloadProgressMaskerVisible + ? context.select>((p) => p.rawStream) + : null, + minZoom: isDownloadProgressMaskerVisible + ? context.select( + (p) => p.downloadableRegion.minZoom, + ) + : 0, + maxZoom: isDownloadProgressMaskerVisible + ? context.select( + (p) => p.downloadableRegion.maxZoom, + ) + : 0, child: tileLayer, ), - /*PolygonLayer( - polygons: [ - Polygon( - points: [ - LatLng(-90, 180), - LatLng(90, 180), - LatLng(90, -180), - LatLng(-90, -180), - ], - holePointsList: [ - const CircleRegion( - LatLng(45.3052535669648, 14.476223064038985), - 5, - ).toOutline().toList(growable: false), - ], - color: Colors.black.withAlpha(255 ~/ 2), - ), - ], - ),*/ if (widget.mode == MapViewMode.downloadRegion) ...[ const RegionShape(), const CustomPolygonSnappingIndicator(), ], - if (widget.bottomPaddingWrapperBuilder != null) - Builder( - builder: (context) => widget.bottomPaddingWrapperBuilder!( - context, - attribution, - ), - ) + if (widget.bottomPaddingWrapperBuilder case final bpwb?) + Builder(builder: (context) => bpwb(context, attribution)) else attribution, ], @@ -472,19 +395,24 @@ class _MapViewState extends State with TickerProviderStateMixin { : SystemMouseCursors.precise, child: map, ), - if (widget.mode == MapViewMode.downloadRegion && - !context.select( - (p) => p.isDownloadSetupPanelVisible, - ) && - context.select( - (p) => p.regionSelectionMethod, - ) == - RegionSelectionMethod.useMapCenter && - !context.select( - (p) => p.customPolygonSnap, - )) - const Center(child: Crosshairs()), + if (isCrosshairsVisible) const Center(child: Crosshairs()), + if (widget.bottomPaddingWrapperBuilder case final bpwb?) + Positioned( + bottom: 8, + right: 8, + child: Builder( + builder: (context) => bpwb( + context, + FMTCNotInUseIndicator(mode: widget.mode), + ), + ), + ) + else + Positioned( + bottom: 16, + right: 16, + child: FMTCNotInUseIndicator(mode: widget.mode), + ), ], ); }, 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 0da0ca0b..a8fcad93 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 @@ -226,14 +226,16 @@ class _ConfirmationPanelState extends State { ), ); - final downloadStream = store.download.startForeground( - region: downloadableRegion, - parallelThreads: downloadConfiguration.parallelThreads, - maxBufferLength: downloadConfiguration.maxBufferLength, - skipExistingTiles: downloadConfiguration.skipExistingTiles, - skipSeaTiles: downloadConfiguration.skipSeaTiles, - rateLimit: downloadConfiguration.rateLimit, - ); + final downloadStream = store.download + .startForeground( + region: downloadableRegion, + parallelThreads: downloadConfiguration.parallelThreads, + maxBufferLength: downloadConfiguration.maxBufferLength, + skipExistingTiles: downloadConfiguration.skipExistingTiles, + skipSeaTiles: downloadConfiguration.skipSeaTiles, + rateLimit: downloadConfiguration.rateLimit, + ) + .asBroadcastStream(); downloadingProvider.assignDownload( storeName: downloadConfiguration.selectedStoreName!, diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/components/no_sub_regions.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/components/no_sub_regions.dart index 702b6906..3ded4fda 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/components/no_sub_regions.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/components/no_sub_regions.dart @@ -23,24 +23,16 @@ class NoSubRegions extends StatelessWidget { 'To bulk download a map, first create a region. Select the ' 'shape above, and tap on the map to add points. Once a ' 'region has been finished, download it immediately, or add ' - 'it to the list of sub-regions to download.', + 'it to the list of (sub-)regions to download.', textAlign: TextAlign.center, ), const Divider(height: 82), const Icon(Icons.view_cozy_outlined, size: 64), const SizedBox(height: 12), Text( - 'No sub-regions selected', + 'No sub-regions constructed', style: Theme.of(context).textTheme.titleLarge, ), - const SizedBox(height: 6), - const Text( - 'FMTC supports `MultiRegion`s formed of multiple other ' - 'regions.\nYou can select an area to download and use the ' - 'panel below to download it, or add it to the list of ' - 'sub-regions using the button above.', - textAlign: TextAlign.center, - ), ], ), ), diff --git a/example/lib/src/shared/state/download_provider.dart b/example/lib/src/shared/state/download_provider.dart index ba6e2d8c..08c45446 100644 --- a/example/lib/src/shared/state/download_provider.dart +++ b/example/lib/src/shared/state/download_provider.dart @@ -17,6 +17,10 @@ class DownloadingProvider extends ChangeNotifier { DownloadProgress? _latestEvent; DownloadProgress get latestEvent => _latestEvent ?? (throw _notReadyError); + Stream? _rawStream; + Stream get rawStream => + _rawStream ?? (throw _notReadyError); + late int _skippedSeaTileCount; int get skippedSeaTileCount => _skippedSeaTileCount; @@ -37,6 +41,8 @@ class DownloadingProvider extends ChangeNotifier { required DownloadableRegion downloadableRegion, required Stream stream, }) { + assert(stream.isBroadcast, 'Input stream must be broadcastable'); + _storeName = storeName; _downloadableRegion = downloadableRegion; @@ -45,6 +51,7 @@ class DownloadingProvider extends ChangeNotifier { _skippedExistingTileSize = 0; _skippedSeaTileSize = 0; + _rawStream = stream; _streamSub = stream.listen( (progress) { if (progress.attemptedTiles == 0) _isFocused = true; diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index 8c655156..afd1ac0b 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -36,11 +36,7 @@ class FMTCTileProvider extends TileProvider { Client? httpClient, @visibleForTesting this.fakeNetworkDisconnect = false, Map? headers, - }) : assert( - storeNames.isNotEmpty || otherStoresStrategy != null, - '`storeNames` cannot be empty if `otherStoresStrategy` is `null`', - ), - urlTransformer = (urlTransformer ?? (u) => u), + }) : urlTransformer = (urlTransformer ?? (u) => u), httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), _wasClientAutomaticallyGenerated = httpClient == null, super( From 0aa119d89253283059f78a2aa3b00d7083d87672 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 10 Nov 2024 12:03:51 +0000 Subject: [PATCH 69/97] Minor improvement --- .../components/greyscale_masker.dart | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) 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 4b72d7e9..b1f403b0 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 @@ -377,7 +377,7 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { final MapEntry(key: greyscaleAmount, value: path) = _greyscalePathCache.entries.elementAt(i); - final greyscalePercentage = greyscaleAmount * 1 / 25; + final greyscalePercentage = greyscaleAmount * 1 / _greyscaleLevelsCount; _layerHandles.elementAt(layerHandleIndex).layer = context.pushColorFilter( offset, @@ -387,14 +387,7 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { offset, Offset.zero & size, path, - (context, offset) { - context.paintChild(child!, offset); - /*context.canvas.clipRect(Offset.zero & size); - context.canvas.drawColor( - Colors.green, - BlendMode.hue, - );*/ - }, + (context, offset) => context.paintChild(child!, offset), clipBehavior: Clip.hardEdge, ), oldLayer: _layerHandles.elementAt(layerHandleIndex).layer, From f3dff17b3194ab7841ac63db96035a07f0ca6d68 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 11 Dec 2024 18:32:41 +0000 Subject: [PATCH 70/97] Refactored `TileEvent` using inheritance and mixins Split output of `StoreDownload.startForeground` into two seperate streams Reworked `DownloadProgress` Added `retryFailedRequestTiles` option to `startForeground` to support retry of failed tiles Renamed `check` to `countTiles` (with deprecation) Minor efficiency improvements in bulk downloading Improved example app --- .../download_progress_masker.dart | 25 +- .../src/screens/main/map_view/map_view.dart | 9 +- .../components/slider_option.dart | 45 ++ .../components/toggle_option.dart | 43 ++ .../config_options/config_options.dart | 136 ++--- .../confirmation_panel.dart | 24 +- .../components/progress/colors.dart | 3 +- .../components/progress/indicator_bars.dart | 25 +- .../components/progress/indicator_text.dart | 160 ++++-- .../components/tile_display/tile_display.dart | 81 ++- .../statistics/components/timing/timing.dart | 8 +- .../components/title_bar/title_bar.dart | 3 +- .../downloading_view_bottom_sheet.dart | 1 + .../downloading/downloading_view_side.dart | 10 +- .../download_configuration_provider.dart | 8 + .../src/shared/state/download_provider.dart | 78 ++- example/pubspec.yaml | 1 + .../external/download_progress.dart | 513 ++++++++++-------- .../bulk_download/external/tile_event.dart | 322 ++++++----- lib/src/bulk_download/internal/manager.dart | 199 ++++--- lib/src/bulk_download/internal/thread.dart | 100 ++-- lib/src/regions/recovered_region.dart | 2 +- lib/src/store/download.dart | 211 ++++--- 23 files changed, 1189 insertions(+), 818 deletions(-) create mode 100644 example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/slider_option.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/toggle_option.dart 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 a0aaa428..676d6be2 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 @@ -14,14 +14,14 @@ part 'components/greyscale_masker.dart'; class DownloadProgressMasker extends StatefulWidget { const DownloadProgressMasker({ super.key, - required this.downloadProgressStream, + required this.tileEvents, required this.minZoom, required this.maxZoom, this.tileSize = 256, required this.child, }); - final Stream? downloadProgressStream; + final Stream? tileEvents; final int minZoom; final int maxZoom; final int tileSize; @@ -34,18 +34,21 @@ class DownloadProgressMasker extends StatefulWidget { class _DownloadProgressMaskerState extends State { @override Widget build(BuildContext context) { - if (widget.downloadProgressStream case final dps?) { + if (widget.tileEvents case final tileEvents?) { return RepaintBoundary( child: StreamBuilder( - stream: dps - .where( - (e) => - e.latestTileEvent != null && !e.latestTileEvent!.isRepeat, - ) - .map((e) => e.latestTileEvent!.coordinates), - builder: (context, snapshot) => GreyscaleMasker( + stream: tileEvents + .where((evt) => evt is SuccessfulTileEvent) + .map((evt) => evt.coordinates), + builder: (context, coords) => GreyscaleMasker( mapCamera: MapCamera.of(context), - latestTileCoordinates: snapshot.data, + latestTileCoordinates: coords.data == null + ? null + : TileCoordinates( + coords.requireData.$1, + coords.requireData.$2, + coords.requireData.$3, + ), minZoom: widget.minZoom, maxZoom: widget.maxZoom, tileSize: widget.tileSize, 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 2613c6e2..87762763 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -337,7 +337,7 @@ class _MapViewState extends State with TickerProviderStateMixin { mapController: _mapController.mapController, options: mapOptions, children: [ - DownloadProgressMasker( + /*DownloadProgressMasker( key: ObjectKey( isDownloadProgressMaskerVisible ? context @@ -346,9 +346,9 @@ class _MapViewState extends State with TickerProviderStateMixin { ) : null, ), - downloadProgressStream: isDownloadProgressMaskerVisible + tileEvents: isDownloadProgressMaskerVisible ? context.select>((p) => p.rawStream) + Stream>((p) => p.rawTileEventStream) : null, minZoom: isDownloadProgressMaskerVisible ? context.select( @@ -361,7 +361,8 @@ class _MapViewState extends State with TickerProviderStateMixin { ) : 0, child: tileLayer, - ), + ),*/ + tileLayer, if (widget.mode == MapViewMode.downloadRegion) ...[ const RegionShape(), const CustomPolygonSnappingIndicator(), diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/slider_option.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/slider_option.dart new file mode 100644 index 00000000..1a7f042e --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/slider_option.dart @@ -0,0 +1,45 @@ +part of '../config_options.dart'; + +class _SliderOption extends StatelessWidget { + const _SliderOption({ + required this.icon, + required this.tooltipMessage, + required this.descriptor, + required this.value, + required this.min, + required this.max, + required this.onChanged, + }); + + final Icon icon; + final String tooltipMessage; + final String descriptor; + final int value; + final int min; + final int max; + final void Function(int value) onChanged; + + @override + Widget build(BuildContext context) => Row( + children: [ + Tooltip(message: tooltipMessage, child: icon), + const SizedBox(width: 6), + Expanded( + child: Slider( + value: value.toDouble(), + min: min.toDouble(), + max: max.toDouble(), + divisions: max - min, + onChanged: (r) => onChanged(r.toInt()), + ), + ), + SizedBox( + width: 72, + child: Text( + '$value $descriptor', + textAlign: TextAlign.end, + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/toggle_option.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/toggle_option.dart new file mode 100644 index 00000000..477028dd --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/toggle_option.dart @@ -0,0 +1,43 @@ +part of '../config_options.dart'; + +class _ToggleOption extends StatelessWidget { + const _ToggleOption({ + required this.icon, + required this.title, + required this.description, + required this.value, + required this.onChanged, + }); + + final Icon icon; + final String title; + final String description; + final bool value; + // ignore: avoid_positional_boolean_parameters + final void Function(bool value) onChanged; + + @override + Widget build(BuildContext context) => Row( + children: [ + icon, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + Text( + description, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + const SizedBox(width: 4), + Switch.adaptive( + value: value, + onChanged: onChanged, + ), + ], + ); +} 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 409f8f9f..aef10680 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 @@ -4,6 +4,9 @@ import 'package:provider/provider.dart'; import '../../../../../../../shared/state/download_configuration_provider.dart'; import 'components/store_selector.dart'; +part 'components/slider_option.dart'; +part 'components/toggle_option.dart'; + class ConfigOptions extends StatefulWidget { const ConfigOptions({super.key}); @@ -33,6 +36,10 @@ class _ConfigOptionsState extends State { ); final skipSeaTiles = context .select((p) => p.skipSeaTiles); + final retryFailedRequestTiles = + context.select( + (p) => p.retryFailedRequestTiles, + ); return SingleChildScrollView( child: Column( @@ -66,60 +73,27 @@ class _ConfigOptionsState extends State { ], ), const Divider(height: 24), - Row( - children: [ - const Tooltip( - message: 'Parallel Threads', - child: Icon(Icons.call_split), - ), - const SizedBox(width: 6), - Expanded( - child: Slider( - value: parallelThreads.toDouble(), - min: 1, - max: 10, - divisions: 9, - onChanged: (r) => context - .read() - .parallelThreads = r.toInt(), - ), - ), - SizedBox( - width: 71, - child: Text( - '$parallelThreads threads', - textAlign: TextAlign.end, - ), - ), - ], + _SliderOption( + icon: const Icon(Icons.call_split), + tooltipMessage: 'Parallel Threads', + descriptor: 'threads', + value: parallelThreads, + min: 1, + max: 10, + onChanged: (v) => context + .read() + .parallelThreads = v, ), const SizedBox(height: 8), - Row( - children: [ - const Tooltip( - message: 'Rate Limit', - child: Icon(Icons.speed), - ), - const SizedBox(width: 6), - Expanded( - child: Slider( - min: 1, - value: rateLimit.toDouble(), - max: 200, - divisions: 199, - onChanged: (r) => context - .read() - .rateLimit = r.toInt(), - ), - ), - SizedBox( - width: 71, - child: Text( - '$rateLimit tps', - textAlign: TextAlign.end, - ), - ), - ], + _SliderOption( + icon: const Icon(Icons.speed), + tooltipMessage: 'Rate Limit', + descriptor: 'tps max', + value: rateLimit, + min: 1, + max: 200, + onChanged: (v) => + context.read().rateLimit = v, ), const SizedBox(height: 8), Row( @@ -149,37 +123,35 @@ 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: skipExistingTiles, - onChanged: (v) => context - .read() - .skipExistingTiles = v, - ), - ], + _ToggleOption( + icon: const Icon(Icons.file_copy), + title: 'Skip Existing Tiles', + description: "Don't attempt tiles that are already cached", + value: skipExistingTiles, + onChanged: (v) => context + .read() + .skipExistingTiles = v, ), - 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: skipSeaTiles, - onChanged: (v) => context - .read() - .skipSeaTiles = v, - ), - ], + const SizedBox(height: 8), + _ToggleOption( + icon: const Icon(Icons.waves), + title: 'Skip Sea Tiles', + description: + "Don't cache tiles with sea/ocean fill as the only visible " + 'element', + value: skipSeaTiles, + onChanged: (v) => + context.read().skipSeaTiles = v, + ), + const SizedBox(height: 8), + _ToggleOption( + icon: const Icon(Icons.plus_one), + title: 'Retry Failed Tiles', + description: 'Retries tiles that failed their HTTP request once', + value: retryFailedRequestTiles, + onChanged: (v) => context + .read() + .retryFailedRequestTiles = v, ), ], ), 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 a8fcad93..fd924319 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 @@ -196,7 +196,8 @@ class _ConfirmationPanelState extends State { } void _updateTileCount() { - _tileCount = const FMTCStore('').download.check(_prevTileCountableRegion!); + _tileCount = + const FMTCStore('').download.countTiles(_prevTileCountableRegion!); setState(() {}); } @@ -226,21 +227,20 @@ class _ConfirmationPanelState extends State { ), ); - final downloadStream = store.download - .startForeground( - region: downloadableRegion, - parallelThreads: downloadConfiguration.parallelThreads, - maxBufferLength: downloadConfiguration.maxBufferLength, - skipExistingTiles: downloadConfiguration.skipExistingTiles, - skipSeaTiles: downloadConfiguration.skipSeaTiles, - rateLimit: downloadConfiguration.rateLimit, - ) - .asBroadcastStream(); + final downloadStreams = store.download.startForeground( + region: downloadableRegion, + parallelThreads: downloadConfiguration.parallelThreads, + maxBufferLength: downloadConfiguration.maxBufferLength, + skipExistingTiles: downloadConfiguration.skipExistingTiles, + skipSeaTiles: downloadConfiguration.skipSeaTiles, + retryFailedRequestTiles: downloadConfiguration.retryFailedRequestTiles, + rateLimit: downloadConfiguration.rateLimit, + ); downloadingProvider.assignDownload( storeName: downloadConfiguration.selectedStoreName!, downloadableRegion: downloadableRegion, - stream: downloadStream, + downloadStreams: downloadStreams, ); // The downloading view is switched to by `assignDownload`, when the first diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/colors.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/colors.dart index b5fa8d42..592ec305 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/colors.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/colors.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; class DownloadingProgressIndicatorColors { static final pendingColor = Colors.grey[350]!; static const failedColor = Colors.red; - static const skippedColor = Colors.orange; + static const retryQueueColor = Colors.orange; + static const skippedColor = Colors.blue; static const successfulColor = Colors.green; } diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_bars.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_bars.dart index 82e9f4ae..c4f6154a 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_bars.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_bars.dart @@ -12,13 +12,24 @@ class ProgressIndicatorBars extends StatelessWidget { @override Widget build(BuildContext context) { final successful = context.select( - (p) => p.latestEvent.successfulTiles / p.latestEvent.maxTiles, + (p) => + p.latestDownloadProgress.successfulTilesCount / + p.latestDownloadProgress.maxTilesCount, ); final skipped = context.select( - (p) => p.latestEvent.skippedTiles / p.latestEvent.maxTiles, + (p) => + p.latestDownloadProgress.skippedTilesCount / + p.latestDownloadProgress.maxTilesCount, ); final failed = context.select( - (p) => p.latestEvent.failedTiles / p.latestEvent.maxTiles, + (p) => + p.latestDownloadProgress.failedTilesCount / + p.latestDownloadProgress.maxTilesCount, + ); + final retryQueue = context.select( + (p) => + p.latestDownloadProgress.retryTilesQueuedCount / + p.latestDownloadProgress.maxTilesCount, ); return ClipRRect( @@ -27,11 +38,17 @@ class ProgressIndicatorBars extends StatelessWidget { child: Stack( children: [ LinearProgressIndicator( - value: successful + skipped + failed, + value: successful + skipped + retryQueue + failed, backgroundColor: DownloadingProgressIndicatorColors.pendingColor, color: DownloadingProgressIndicatorColors.failedColor, minHeight: _barHeight, ), + LinearProgressIndicator( + value: successful + skipped + retryQueue, + backgroundColor: Colors.transparent, + color: DownloadingProgressIndicatorColors.retryQueueColor, + minHeight: _barHeight, + ), LinearProgressIndicator( value: successful + skipped, backgroundColor: Colors.transparent, diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart index db8bf066..c2dcd8df 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -15,82 +13,114 @@ class ProgressIndicatorText extends StatefulWidget { } class _ProgressIndicatorTextState extends State { - late final Timer _rawPercentAlternator; bool _usePercentages = false; - @override - void initState() { - super.initState(); - _rawPercentAlternator = Timer.periodic( - const Duration(seconds: 2), - (_) => setState(() => _usePercentages = !_usePercentages), - ); - } - - @override - void dispose() { - _rawPercentAlternator.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final cachedTilesCount = context.select( - (p) => p.latestEvent.cachedTiles - p.latestEvent.bufferedTiles, + final successfulFlushedTilesCount = + context.select( + (p) => p.latestDownloadProgress.flushedTilesCount, ); - final cachedTilesSize = context.select( - (p) => p.latestEvent.cachedSize - p.latestEvent.bufferedSize, - ) * - 1024; - - final bufferedTilesCount = context - .select((p) => p.latestEvent.bufferedTiles); - final bufferedTilesSize = context.select( - (p) => p.latestEvent.bufferedSize, + final successfulFlushedTilesSize = + context.select( + (p) => p.latestDownloadProgress.flushedTilesSize, + ) * + 1024; + + final successfulBufferedTilesCount = + context.select( + (p) => p.latestDownloadProgress.bufferedTilesCount, + ); + final successfulBufferedTilesSize = + context.select( + (p) => p.latestDownloadProgress.bufferedTilesSize, + ) * + 1024; + + final skippedExistingTilesCount = context.select( + (p) => p.latestDownloadProgress.existingTilesCount, + ); + final skippedExistingTilesSize = + context.select( + (p) => p.latestDownloadProgress.existingTilesSize, + ) * + 1024; + + final skippedSeaTilesCount = context.select( + (p) => p.latestDownloadProgress.seaTilesCount, + ); + final skippedSeaTilesSize = context.select( + (p) => p.latestDownloadProgress.seaTilesSize, ) * 1024; - final skippedExistingTilesCount = context - .select((p) => p.skippedExistingTileCount); - final skippedExistingTilesSize = context - .select((p) => p.skippedExistingTileSize); + final failedNegativeResponseTilesCount = + context.select( + (p) => p.latestDownloadProgress.negativeResponseTilesCount, + ); - final skippedSeaTilesCount = - context.select((p) => p.skippedSeaTileCount); - final skippedSeaTilesSize = - context.select((p) => p.skippedSeaTileSize); + final failedFailedRequestTilesCount = + context.select( + (p) => p.latestDownloadProgress.failedRequestTilesCount, + ); - final failedTilesCount = context - .select((p) => p.latestEvent.failedTiles); + final retryTilesQueuedCount = context.select( + (p) => p.latestDownloadProgress.retryTilesQueuedCount, + ); - final pendingTilesCount = context - .select((p) => p.latestEvent.remainingTiles); + final remainingTilesCount = context.select( + (p) => p.latestDownloadProgress.remainingTilesCount, + ) - + retryTilesQueuedCount; - final maxTilesCount = - context.select((p) => p.latestEvent.maxTiles); + final maxTilesCount = context.select( + (p) => p.latestDownloadProgress.maxTilesCount, + ); return Column( children: [ + Align( + alignment: Alignment.centerRight, + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.numbers), + tooltip: 'Show tile counts', + ), + ButtonSegment( + value: true, + icon: Icon(Icons.percent), + tooltip: 'Show percentages', + ), + ], + selected: {_usePercentages}, + onSelectionChanged: (v) => + setState(() => _usePercentages = v.single), + showSelectedIcon: false, + ), + ), + const SizedBox(height: 8), _TextRow( color: DownloadingProgressIndicatorColors.successfulColor, type: 'Successful', statistic: _usePercentages - ? '${(((cachedTilesCount + bufferedTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}% ' - : '${cachedTilesCount + bufferedTilesCount} tiles (${(cachedTilesSize + bufferedTilesSize).asReadableSize})', + ? '${(((successfulFlushedTilesCount + successfulBufferedTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}% ' + : '${successfulFlushedTilesCount + successfulBufferedTilesCount} tiles (${(successfulFlushedTilesSize + successfulBufferedTilesSize).asReadableSize})', ), const SizedBox(height: 4), _TextRow( - type: 'Cached', + type: 'Flushed', statistic: _usePercentages - ? '${((cachedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}% ' - : '$cachedTilesCount tiles (${cachedTilesSize.asReadableSize})', + ? '${((successfulFlushedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}% ' + : '$successfulFlushedTilesCount tiles (${successfulFlushedTilesSize.asReadableSize})', ), const SizedBox(height: 4), _TextRow( type: 'Buffered', statistic: _usePercentages - ? '${((bufferedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' - : '$bufferedTilesCount tiles (${bufferedTilesSize.asReadableSize})', + ? '${((successfulBufferedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$successfulBufferedTilesCount tiles (${successfulBufferedTilesSize.asReadableSize})', ), const SizedBox(height: 4), _TextRow( @@ -119,16 +149,38 @@ class _ProgressIndicatorTextState extends State { color: DownloadingProgressIndicatorColors.failedColor, type: 'Failed', statistic: _usePercentages - ? '${((failedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' - : '$failedTilesCount tiles', + ? '${(((failedNegativeResponseTilesCount + failedFailedRequestTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '${failedNegativeResponseTilesCount + failedFailedRequestTilesCount} tiles', + ), + const SizedBox(height: 4), + _TextRow( + type: 'Negative Response', + statistic: _usePercentages + ? '${((failedNegativeResponseTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$failedNegativeResponseTilesCount tiles', + ), + const SizedBox(height: 4), + _TextRow( + type: 'Failed Request', + statistic: _usePercentages + ? '${((failedFailedRequestTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$failedFailedRequestTilesCount tiles', + ), + const SizedBox(height: 4), + _TextRow( + color: DownloadingProgressIndicatorColors.retryQueueColor, + type: 'Queued For Retry', + statistic: _usePercentages + ? '${((retryTilesQueuedCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$retryTilesQueuedCount tiles', ), const SizedBox(height: 4), _TextRow( color: DownloadingProgressIndicatorColors.pendingColor, type: 'Pending', statistic: _usePercentages - ? '${((pendingTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' - : '$pendingTilesCount/$maxTilesCount tiles', + ? '${((remainingTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + : '$remainingTilesCount/$maxTilesCount tiles', ), ], ); diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart index 1857a360..5244f6a3 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.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_provider.dart'; @@ -6,29 +7,64 @@ import '../../../../../../../../../shared/state/download_provider.dart'; class TileDisplay extends StatelessWidget { const TileDisplay({super.key}); - static const _dimension = 180.0; + static const _dimension = 200.0; @override Widget build(BuildContext context) { - if (context - .watch() - .latestEvent - .latestTileEvent - ?.tileImage - case final tileImage?) { - return Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), + if (context.watch().isComplete) { + return const SizedBox.shrink(); + } + + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(width: 2, color: Theme.of(context).dividerColor), + ), child: SizedBox.square( dimension: _dimension, child: Stack( children: [ - Image.memory( - tileImage, - cacheHeight: _dimension.toInt(), - cacheWidth: _dimension.toInt(), - gaplessPlayback: true, - ), + if (context.watch().latestTileEvent == + null) + const Center(child: CircularProgressIndicator.adaptive()) + else if (context.watch().latestTileEvent + case final TileEventImage tile) + Image.memory( + tile.tileImage, + cacheHeight: _dimension.toInt(), + cacheWidth: _dimension.toInt(), + gaplessPlayback: true, + ) + else + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + context.watch().latestTileEvent + is FailedRequestTileEvent + ? Icons + .signal_wifi_connected_no_internet_4_outlined + : Icons + .signal_cellular_connected_no_internet_4_bar_outlined, + size: 48, + color: Colors.red, + ), + Text( + context.watch().latestTileEvent + is FailedRequestTileEvent + ? 'Failed request' + : 'Negative response', + style: const TextStyle( + color: Colors.red, + ), + ), + ], + ), + ), Positioned( bottom: 0, left: 0, @@ -50,19 +86,6 @@ class TileDisplay extends StatelessWidget { ), ), ), - ); - } - - if (context.watch().latestEvent.isComplete) { - return const SizedBox.shrink(); - } - - return const Center( - child: SizedBox.square( - dimension: _dimension, - child: Center( - child: CircularProgressIndicator.adaptive(), - ), ), ); } diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/timing/timing.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/timing/timing.dart index 24ff71bb..1af127cf 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/timing/timing.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/timing/timing.dart @@ -15,7 +15,7 @@ class _TimingStatsState extends State { @override Widget build(BuildContext context) { final estRemainingDuration = context.select( - (p) => p.latestEvent.estRemainingDuration, + (p) => p.latestDownloadProgress.estRemainingDuration, ); return Column( @@ -30,7 +30,7 @@ class _TimingStatsState extends State { Text( _formatDuration( context.select( - (p) => p.latestEvent.elapsedDuration, + (p) => p.latestDownloadProgress.elapsedDuration, ), ), style: Theme.of(context).textTheme.titleLarge, @@ -47,13 +47,13 @@ class _TimingStatsState extends State { Text( context .select( - (p) => p.latestEvent.tilesPerSecond, + (p) => p.latestDownloadProgress.tilesPerSecond, ) .toStringAsFixed(0), style: Theme.of(context).textTheme.titleLarge, ), if (context.select( - (p) => p.latestEvent.tilesPerSecond, + (p) => p.latestDownloadProgress.tilesPerSecond, ) >= context.select( (p) => p.rateLimit, diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/title_bar/title_bar.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/title_bar/title_bar.dart index 780b1947..7448dc3d 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/title_bar/title_bar.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/title_bar/title_bar.dart @@ -9,8 +9,7 @@ class TitleBar extends StatelessWidget { @override Widget build(BuildContext context) { - if (context - .select((p) => p.latestEvent.isComplete)) { + if (context.select((p) => p.isComplete)) { return IntrinsicHeight( child: Row( children: [ diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart index e69de29b..8b137891 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart @@ -0,0 +1 @@ + diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart index f2050675..6495438d 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart @@ -32,10 +32,7 @@ class _DownloadingViewSideState extends State { padding: const EdgeInsets.all(4), child: IconButton( onPressed: () async { - if (context - .read() - .latestEvent - .isComplete) { + if (context.read().isComplete) { context.read() ..isDownloadSetupPanelVisible = false ..clearConstructedRegions() @@ -54,9 +51,8 @@ class _DownloadingViewSideState extends State { ), ), const SizedBox(width: 12), - if (context.select( - (p) => !p.latestEvent.isComplete, - )) + if (context + .select((p) => !p.isComplete)) Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(99), diff --git a/example/lib/src/shared/state/download_configuration_provider.dart b/example/lib/src/shared/state/download_configuration_provider.dart index 343e130d..ed90df5c 100644 --- a/example/lib/src/shared/state/download_configuration_provider.dart +++ b/example/lib/src/shared/state/download_configuration_provider.dart @@ -11,6 +11,7 @@ class DownloadConfigurationProvider extends ChangeNotifier { maxBufferLength: 0, skipExistingTiles: false, skipSeaTiles: true, + retryFailedRequestTiles: true, ); int _minZoom = defaultValues.minZoom; @@ -76,6 +77,13 @@ class DownloadConfigurationProvider extends ChangeNotifier { notifyListeners(); } + bool _retryFailedRequestTiles = defaultValues.retryFailedRequestTiles; + bool get retryFailedRequestTiles => _retryFailedRequestTiles; + set retryFailedRequestTiles(bool newState) { + _retryFailedRequestTiles = newState; + notifyListeners(); + } + String? _selectedStoreName; String? get selectedStoreName => _selectedStoreName; set selectedStoreName(String? newStoreName) { diff --git a/example/lib/src/shared/state/download_provider.dart b/example/lib/src/shared/state/download_provider.dart index 08c45446..47e0ab9f 100644 --- a/example/lib/src/shared/state/download_provider.dart +++ b/example/lib/src/shared/state/download_provider.dart @@ -10,70 +10,59 @@ class DownloadingProvider extends ChangeNotifier { bool _isPaused = false; bool get isPaused => _isPaused; + bool _isComplete = false; + bool get isComplete => _isComplete; + DownloadableRegion? _downloadableRegion; DownloadableRegion get downloadableRegion => _downloadableRegion ?? (throw _notReadyError); - DownloadProgress? _latestEvent; - DownloadProgress get latestEvent => _latestEvent ?? (throw _notReadyError); - - Stream? _rawStream; - Stream get rawStream => - _rawStream ?? (throw _notReadyError); - - late int _skippedSeaTileCount; - int get skippedSeaTileCount => _skippedSeaTileCount; + DownloadProgress? _latestDownloadProgress; + DownloadProgress get latestDownloadProgress => + _latestDownloadProgress ?? (throw _notReadyError); - late int _skippedSeaTileSize; - int get skippedSeaTileSize => _skippedSeaTileSize; + TileEvent? _latestTileEvent; + TileEvent? get latestTileEvent => _latestTileEvent; - late int _skippedExistingTileCount; - int get skippedExistingTileCount => _skippedExistingTileCount; - - late int _skippedExistingTileSize; - int get skippedExistingTileSize => _skippedExistingTileSize; + Stream? _rawTileEventsStream; + Stream get rawTileEventStream => + _rawTileEventsStream ?? (throw _notReadyError); late String _storeName; - late StreamSubscription _streamSub; void assignDownload({ required String storeName, required DownloadableRegion downloadableRegion, - required Stream stream, + required ({ + Stream downloadProgress, + Stream tileEvents + }) downloadStreams, }) { - assert(stream.isBroadcast, 'Input stream must be broadcastable'); - _storeName = storeName; _downloadableRegion = downloadableRegion; - _skippedExistingTileCount = 0; - _skippedSeaTileCount = 0; - _skippedExistingTileSize = 0; - _skippedSeaTileSize = 0; - - _rawStream = stream; - _streamSub = stream.listen( - (progress) { - if (progress.attemptedTiles == 0) _isFocused = true; - _latestEvent = progress; - - final latestTile = progress.latestTileEvent; - - if (latestTile != null && !latestTile.isRepeat) { - if (latestTile.result == TileEventResult.alreadyExisting) { - _skippedExistingTileCount++; - _skippedExistingTileSize += latestTile.tileImage!.lengthInBytes; - } - if (latestTile.result == TileEventResult.isSeaTile) { - _skippedSeaTileCount++; - _skippedSeaTileSize += latestTile.tileImage!.lengthInBytes; - } - } + _rawTileEventsStream = downloadStreams.tileEvents.asBroadcastStream(); + downloadStreams.downloadProgress.listen( + (evt) { + // Focus on initial event + if (evt.attemptedTilesCount == 0) _isFocused = true; + + // Update stored value + _latestDownloadProgress = evt; + notifyListeners(); + }, + onDone: () { + _isComplete = true; notifyListeners(); }, - onDone: () => _streamSub.cancel(), ); + + downloadStreams.tileEvents.listen((evt) { + // Update stored value + _latestTileEvent = evt; + notifyListeners(); + }); } Future pause() async { @@ -92,6 +81,7 @@ class DownloadingProvider extends ChangeNotifier { void reset() { _isFocused = false; + _isComplete = false; notifyListeners(); } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1868601b..55bde925 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -34,6 +34,7 @@ dependency_overrides: flutter_map: git: url: https://github.com/fleaflet/flutter_map.git + ref: d816b4d54f9245e260b125ea1adbf300b5c39843 flutter_map_tile_caching: path: ../ diff --git a/lib/src/bulk_download/external/download_progress.dart b/lib/src/bulk_download/external/download_progress.dart index 54ca3d72..b2d663aa 100644 --- a/lib/src/bulk_download/external/download_progress.dart +++ b/lib/src/bulk_download/external/download_progress.dart @@ -8,121 +8,258 @@ part of '../../../flutter_map_tile_caching.dart'; /// See the documentation on each individual property for more information. @immutable class DownloadProgress { - const DownloadProgress.__({ - required this.latestTileEvent, - required this.cachedTiles, - required this.cachedSize, - required this.bufferedTiles, - required this.bufferedSize, - required this.skippedTiles, - required this.skippedSize, - required this.failedTiles, - required this.maxTiles, + /// Raw constructor + /// + /// Note that [maxTilesCount], [_tilesPerSecondLimit] & + /// [_retryFailedRequestTiles] are set here (or in + /// [DownloadProgress._initial]), and are not modified for a download by + /// update + @protected + const DownloadProgress._({ + required this.flushedTilesCount, + required this.flushedTilesSize, + required this.bufferedTilesCount, + required this.bufferedTilesSize, + required this.seaTilesCount, + required this.seaTilesSize, + required this.existingTilesCount, + required this.existingTilesSize, + required this.negativeResponseTilesCount, + required this.failedRequestTilesCount, + required this.retryTilesQueuedCount, + required this.maxTilesCount, required this.elapsedDuration, required this.tilesPerSecond, - required this.isTPSArtificiallyCapped, - required this.isComplete, - }); - - factory DownloadProgress._initial({required int maxTiles}) => - DownloadProgress.__( - latestTileEvent: null, - cachedTiles: 0, - cachedSize: 0, - bufferedTiles: 0, - bufferedSize: 0, - skippedTiles: 0, - skippedSize: 0, - failedTiles: 0, - maxTiles: maxTiles, - elapsedDuration: Duration.zero, - tilesPerSecond: 0, - isTPSArtificiallyCapped: false, - isComplete: false, - ); + required int? tilesPerSecondLimit, + required bool retryFailedRequestTiles, + }) : _tilesPerSecondLimit = tilesPerSecondLimit, + _retryFailedRequestTiles = retryFailedRequestTiles; + + /// Setup an initial download progress + /// + /// Note that [maxTilesCount], [_tilesPerSecondLimit] & + /// [_retryFailedRequestTiles] are set here (or in [DownloadProgress._]), and + /// are not modified for a download by update. + const DownloadProgress._initial({ + required this.maxTilesCount, + required int? tilesPerSecondLimit, + required bool retryFailedRequestTiles, + }) : flushedTilesCount = 0, + flushedTilesSize = 0, + bufferedTilesCount = 0, + bufferedTilesSize = 0, + seaTilesCount = 0, + seaTilesSize = 0, + existingTilesCount = 0, + existingTilesSize = 0, + negativeResponseTilesCount = 0, + failedRequestTilesCount = 0, + retryTilesQueuedCount = 0, + elapsedDuration = Duration.zero, + tilesPerSecond = 0, + _tilesPerSecondLimit = tilesPerSecondLimit, + _retryFailedRequestTiles = retryFailedRequestTiles; - /// The result of the latest attempted tile, if applicable + /// Create a new progress object based on the existing one, due to a new tile + /// event /// - /// `null` if the first tile has not yet been downloaded, or the download has - /// completed. The completion of a download may be considered to be reported - /// twice, although only the one with [isComplete] should be taken as the - /// final indicator: the last tile may (or may not) make all the statistics - /// 100%, but it will not set [isComplete]. An event is emitted after the - /// final tile, with no tile set, but [isComplete] set. + /// [newTileEvent] should be provided for non-[SuccessfulTileEvent]s: this + /// will be used to automatically update all neccessary statistics. /// - /// A similar [TileEvent] may be emitted multiple times sequentially, due to - /// fallback reporting (configurable in [StoreDownload.startForeground]). In - /// this event, this is not set `null`, but [TileEvent.isRepeat] will be set. - /// If this flag is set, the tile should not be counted as a new event in any - /// listener. + /// For [SuccessfulTileEvent]s, the flushed and buffered metrics cannot be + /// automatically updated from information in the tile event alone. + /// [flushedTiles] & [bufferedTiles] should be updated manually. /// - /// {@macro fmtc.tileevent.extraConsiderations} - final TileEvent? latestTileEvent; + /// [maxTilesCount], [_tilesPerSecondLimit] & [_retryFailedRequestTiles] may + /// not be modified. [elapsedDuration] & [tilesPerSecond] must always be + /// modified. + DownloadProgress _updateWithTile({ + required TileEvent newTileEvent, + ({int count, double size})? flushedTiles, + ({int count, double size})? bufferedTiles, + required Duration elapsedDuration, + required double tilesPerSecond, + }) => + DownloadProgress._( + flushedTilesCount: flushedTiles?.count ?? flushedTilesCount, + flushedTilesSize: flushedTiles?.size ?? flushedTilesSize, + bufferedTilesCount: bufferedTiles?.count ?? bufferedTilesCount, + bufferedTilesSize: bufferedTiles?.size ?? bufferedTilesSize, + seaTilesCount: seaTilesCount + (newTileEvent is SeaTileEvent ? 1 : 0), + seaTilesSize: seaTilesSize + + (newTileEvent is SeaTileEvent + ? newTileEvent.tileImage.lengthInBytes / 1024 + : 0), + existingTilesCount: + existingTilesCount + (newTileEvent is ExistingTileEvent ? 1 : 0), + existingTilesSize: existingTilesSize + + (newTileEvent is ExistingTileEvent + ? newTileEvent.tileImage.lengthInBytes / 1024 + : 0), + negativeResponseTilesCount: negativeResponseTilesCount + + (newTileEvent is NegativeResponseTileEvent ? 1 : 0), + failedRequestTilesCount: failedRequestTilesCount + + (newTileEvent is FailedRequestTileEvent && + (newTileEvent.wasRetryAttempt || !_retryFailedRequestTiles) + ? 1 + : 0), + retryTilesQueuedCount: retryTilesQueuedCount + + (newTileEvent is FailedRequestTileEvent && + _retryFailedRequestTiles && + !newTileEvent.wasRetryAttempt + ? 1 + : newTileEvent.wasRetryAttempt + ? -1 + : 0), + maxTilesCount: maxTilesCount, + elapsedDuration: elapsedDuration, + tilesPerSecond: tilesPerSecond, + tilesPerSecondLimit: _tilesPerSecondLimit, + retryFailedRequestTiles: _retryFailedRequestTiles, + ); - /// The number of new tiles successfully downloaded and in the tile buffer or - /// cached + /// Create a new progress object based on the existing one, without a new tile + DownloadProgress _updateWithoutTile({ + required Duration elapsedDuration, + required double tilesPerSecond, + }) => + DownloadProgress._( + flushedTilesCount: flushedTilesCount, + flushedTilesSize: flushedTilesSize, + bufferedTilesCount: bufferedTilesCount, + bufferedTilesSize: bufferedTilesSize, + seaTilesCount: seaTilesCount, + seaTilesSize: seaTilesSize, + existingTilesCount: existingTilesCount, + existingTilesSize: existingTilesSize, + negativeResponseTilesCount: negativeResponseTilesCount, + failedRequestTilesCount: failedRequestTilesCount, + retryTilesQueuedCount: retryTilesQueuedCount, + maxTilesCount: maxTilesCount, + elapsedDuration: elapsedDuration, + tilesPerSecond: tilesPerSecond, + tilesPerSecondLimit: _tilesPerSecondLimit, + retryFailedRequestTiles: _retryFailedRequestTiles, + ); + + /// The number of tiles remaining to be attempted to download /// - /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. + /// This includes [retryTilesQueuedCount] as tiles remaining. + int get remainingTilesCount => + maxTilesCount - (attemptedTilesCount - retryTilesQueuedCount); + + /// The number of tiles that have been attempted to download /// - /// Includes [bufferedTiles]. - final int cachedTiles; + /// Attempted means they were successful ([successfulTilesCount]), skipped + /// ([skippedTilesCount]), or failed ([failedTilesCount]). Additionally, this + /// also includes [retryTilesQueuedCount]. + int get attemptedTilesCount => + successfulTilesCount + + skippedTilesCount + + failedTilesCount + + retryTilesQueuedCount; - /// The total size (in KiB) of new tiles successfully downloaded and in the - /// tile buffer or cached + /// The number of tiles successfully downloaded (including both tiles buffered + /// and actually flushed/written to cache) /// - /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. + /// This is the number of [SuccessfulTileEvent]s emitted. + int get successfulTilesCount => flushedTilesCount + bufferedTilesCount; + + /// The size in KiB of the tile images successfully downloaded (including both + /// tiles buffered and actually flushed/written to cache) + double get successfulTilesSize => flushedTilesSize + bufferedTilesSize; + + /// The number of tiles successfully downloaded and written to the cache + /// (flushed from the buffer) + final int flushedTilesCount; + + /// The size in KiB of the tile images successfully downloaded and written to + /// the cache (flushed from the buffer) + final double flushedTilesSize; + + /// The number of tiles successfully downloaded but still to be written to the + /// cache /// - /// Includes [bufferedSize]. - final double cachedSize; + /// These tiles are volatile and will be lost if the download stops + /// unexpectedly. However, they will be re-attempted if the download is + /// recovered. + final int bufferedTilesCount; - /// The number of new tiles successfully downloaded and in the tile buffer - /// waiting to be cached + /// The size in KiB of the tile images successfully downloaded but still to be + /// written to the cache /// - /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. + /// These tiles are volatile and will be lost if the download stops + /// unexpectedly. However, they will be re-attempted if the download is + /// recovered. + final double bufferedTilesSize; + + /// The number of tiles skipped (including both sea tiles and existing tiles, + /// where their respective options are enabled when starting the download) /// - /// Part of [cachedTiles]. - final int bufferedTiles; + /// This is the number of [SkippedTileEvent]s emitted. + int get skippedTilesCount => seaTilesCount + existingTilesCount; + + /// The size in KiB of the tile images skipped (including both sea tiles and + /// existing tiles, where their respective options are enabled when starting + /// the download) + double get skippedTilesSize => seaTilesSize + existingTilesSize; - /// The total size (in KiB) of new tiles successfully downloaded and in the - /// tile buffer waiting to be cached + /// The number of tiles skipped because they were sea tiles and `skipSeaTiles` + /// was enabled /// - /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. + /// This is the number of [SeaTileEvent]s emitted. + final int seaTilesCount; + + /// The size in KiB of the tile images skipped because they were sea tiles and + /// `skipSeaTiles` was enabled + final double seaTilesSize; + + /// The number of tiles skipped because they already existed in the cache and + /// `skipExistingTiles` was enabled /// - /// Part of [cachedSize]. - final double bufferedSize; + /// This is the number of [ExistingTileEvent]s emitted. + final int existingTilesCount; - /// The number of tiles that were skipped (not cached) because they either: - /// - already existed & `skipExistingTiles` was `true` - /// - were a sea tile & `skipSeaTiles` was `true` + /// The size in KiB of the tile images skipped because they already existed in + /// the cache and `skipExistingTiles` was enabled + final double existingTilesSize; + + /// The number of tiles that could not be downloaded and are not in the queue + /// to be retried ([retryTilesQueuedCount]) /// - /// [TileEvent]s with the result category of [TileEventResultCategory.skipped]. - final int skippedTiles; + /// See [failedRequestTilesCount] for more information about how that metric + /// is affected by retry tiles. + int get failedTilesCount => + negativeResponseTilesCount + failedRequestTilesCount; - /// The total size (in KiB) of tiles that were skipped (not cached) because - /// they either: - /// - already existed & `skipExistingTiles` was `true` - /// - were a sea tile & `skipSeaTiles` was `true` + /// The number of tiles that could not be downloaded because the HTTP response + /// was not 200 OK /// - /// [TileEvent]s with the result category of [TileEventResultCategory.skipped]. - final double skippedSize; + /// This is the number of [NegativeResponseTileEvent]s emitted. + final int negativeResponseTilesCount; - /// The number of tiles that were not successfully downloaded, potentially for - /// a variety of reasons + /// The number of tiles that could not be downloaded because the HTTP request + /// could not be made /// - /// [TileEvent]s with the result category of [TileEventResultCategory.failed]. + /// Where `retryFailedRequestTiles` is disabled, this is the number of + /// [FailedRequestTileEvent]s emitted. Otherwise, this is the number of + /// [FailedRequestTileEvent]s emitted only where + /// [FailedRequestTileEvent.wasRetryAttempt] is `true`. + final int failedRequestTilesCount; + + /// The number of tiles that were queued to be retried /// - /// To check why these tiles failed, use [latestTileEvent] to construct a list - /// of tiles that failed. - final int failedTiles; + /// See [StoreDownload.startForeground] for more info. + final int retryTilesQueuedCount; /// The total number of tiles available to be potentially downloaded and /// cached /// - /// The difference between [DownloadableRegion.end] - - /// [DownloadableRegion.start] (assuming the maximum number of tiles actually - /// available in the region, as determined by [StoreDownload.check], if - /// [DownloadableRegion.end] is `null`). - final int maxTiles; + /// The difference between [DownloadableRegion.end] and + /// [DownloadableRegion.start]. If there is no endpoint set, this is the + /// the maximum number of tiles actually available in the region, as determined + /// by [StoreDownload.countTiles]. + final int maxTilesCount; /// The current elapsed duration of the download /// @@ -140,170 +277,86 @@ class DownloadProgress { /// capped by the set `rateLimit` /// /// This is only an approximate indicator. - final bool isTPSArtificiallyCapped; - - /// Whether the download is now complete - /// - /// There will be no more events after this event, regardless of other - /// statistics. - /// - /// Prefer using this over checking any other statistics for completion. If all - /// threads have unexpectedly quit due to an error, the other statistics will - /// not indicate the the download has stopped/finished/completed, but this will - /// be `true`. - final bool isComplete; + bool get isTPSArtificiallyCapped => + _tilesPerSecondLimit != null && + (tilesPerSecond >= _tilesPerSecondLimit - 0.5); - /// The number of tiles that were either cached, in buffer, or skipped - /// - /// Equal to [cachedTiles] + [skippedTiles]. - int get successfulTiles => cachedTiles + skippedTiles; + /// The percentage [attemptedTilesCount] is of [maxTilesCount] (expressed + /// from 0 - 100) + double get percentageProgress => (attemptedTilesCount / maxTilesCount) * 100; - /// The total size (in KiB) of tiles that were either cached, in buffer, or - /// skipped + /// The estimated total duration of the download /// - /// Equal to [cachedSize] + [skippedSize]. - double get successfulSize => cachedSize + skippedSize; - - /// The number of tiles that have been attempted, with any result + /// If the [tilesPerSecond] is 0 or very small, then the reported duration + /// is [Duration.zero]. /// - /// Equal to [successfulTiles] + [failedTiles]. - int get attemptedTiles => successfulTiles + failedTiles; - - /// The number of tiles that have not yet been attempted - /// - /// Equal to [maxTiles] - [attemptedTiles]. - int get remainingTiles => maxTiles - attemptedTiles; - - /// The number of attempted tiles over the number of available tiles as a - /// percentage + /// No accuracy guarantees are given. Precision to 1 second. /// - /// Equal to [attemptedTiles] / [maxTiles] multiplied by 100. - double get percentageProgress => (attemptedTiles / maxTiles) * 100; + /// > [!TIP] + /// > It is not recommended to display this value directly to your user. + /// > Instead, prefer using language such as 'about 𝑥 minutes remaining'. + Duration get estTotalDuration => switch (tilesPerSecond) { + < 0 => throw RangeError('Impossible `tilesPerSecond`'), + == 0 || < 0.1 => Duration.zero, + _ => Duration( + seconds: max( + elapsedDuration.inSeconds, // Prevent negative time remaining + (maxTilesCount / tilesPerSecond).round(), + ), + ), + }; - /// The estimated total duration of the download - /// - /// It may or may not be accurate, except when [isComplete] is `true`, in which - /// event, this will always equal [elapsedDuration]. - /// - /// It is not recommended to display this value directly to your user. Instead, - /// prefer using language such as 'about 𝑥 minutes remaining'. - Duration get estTotalDuration => isComplete - ? elapsedDuration - : Duration( - seconds: - (((maxTiles / tilesPerSecond.clamp(1, largestInt)) / 10).round() * - 10) - .clamp(elapsedDuration.inSeconds, largestInt), - ); - - /// The estimated remaining duration of the download. + /// The estimated remaining duration of the download /// - /// It may or may not be accurate. + /// No accuracy guarantees are given. /// - /// It is not recommended to display this value directly to your user. Instead, - /// prefer using language such as 'about 𝑥 minutes remaining'. - Duration get estRemainingDuration => - estTotalDuration - elapsedDuration < Duration.zero - ? Duration.zero - : estTotalDuration - elapsedDuration; - - DownloadProgress _fallbackReportUpdate({ - required Duration newDuration, - required double tilesPerSecond, - required int? rateLimit, - }) => - DownloadProgress.__( - latestTileEvent: latestTileEvent?._copyWithRepeat(), - cachedTiles: cachedTiles, - cachedSize: cachedSize, - bufferedTiles: bufferedTiles, - bufferedSize: bufferedSize, - skippedTiles: skippedTiles, - skippedSize: skippedSize, - failedTiles: failedTiles, - maxTiles: maxTiles, - elapsedDuration: newDuration, - tilesPerSecond: tilesPerSecond, - isTPSArtificiallyCapped: - tilesPerSecond >= (rateLimit ?? double.infinity) - 0.5, - isComplete: false, - ); - - DownloadProgress _updateProgressWithTile({ - required TileEvent? newTileEvent, - required int newBufferedTiles, - required double newBufferedSize, - required Duration newDuration, - required double tilesPerSecond, - required int? rateLimit, - bool isComplete = false, - }) { - final isNewTile = newTileEvent != null; - return DownloadProgress.__( - latestTileEvent: newTileEvent, - cachedTiles: isNewTile && - newTileEvent.result.category == TileEventResultCategory.cached - ? cachedTiles + 1 - : cachedTiles, - cachedSize: isNewTile && - newTileEvent.result.category == TileEventResultCategory.cached - ? cachedSize + (newTileEvent.tileImage!.lengthInBytes / 1024) - : cachedSize, - bufferedTiles: newBufferedTiles, - bufferedSize: newBufferedSize, - skippedTiles: isNewTile && - newTileEvent.result.category == TileEventResultCategory.skipped - ? skippedTiles + 1 - : skippedTiles, - skippedSize: isNewTile && - newTileEvent.result.category == TileEventResultCategory.skipped - ? skippedSize + (newTileEvent.tileImage!.lengthInBytes / 1024) - : skippedSize, - failedTiles: isNewTile && - newTileEvent.result.category == TileEventResultCategory.failed - ? failedTiles + 1 - : failedTiles, - maxTiles: maxTiles, - elapsedDuration: newDuration, - tilesPerSecond: tilesPerSecond, - isTPSArtificiallyCapped: - tilesPerSecond >= (rateLimit ?? double.infinity) - 0.5, - isComplete: isComplete, - ); + /// > [!TIP] + /// > It is not recommended to display this value directly to your user. + /// > Instead, prefer using language such as 'about 𝑥 minutes remaining'. + Duration get estRemainingDuration { + final rawRemaining = estTotalDuration - elapsedDuration; + return rawRemaining < Duration.zero ? Duration.zero : rawRemaining; } + final int? _tilesPerSecondLimit; + final bool _retryFailedRequestTiles; + @override bool operator ==(Object other) => identical(this, other) || (other is DownloadProgress && - latestTileEvent == other.latestTileEvent && - cachedTiles == other.cachedTiles && - cachedSize == other.cachedSize && - bufferedTiles == other.bufferedTiles && - bufferedSize == other.bufferedSize && - skippedTiles == other.skippedTiles && - skippedSize == other.skippedSize && - failedTiles == other.failedTiles && - maxTiles == other.maxTiles && + flushedTilesCount == other.flushedTilesCount && + flushedTilesSize == other.flushedTilesSize && + bufferedTilesCount == other.bufferedTilesCount && + bufferedTilesSize == other.bufferedTilesSize && + seaTilesCount == other.seaTilesCount && + seaTilesSize == other.seaTilesSize && + existingTilesCount == other.existingTilesCount && + existingTilesSize == other.existingTilesSize && + negativeResponseTilesCount == other.negativeResponseTilesCount && + failedRequestTilesCount == other.failedRequestTilesCount && + retryTilesQueuedCount == other.retryTilesQueuedCount && + maxTilesCount == other.maxTilesCount && elapsedDuration == other.elapsedDuration && tilesPerSecond == other.tilesPerSecond && - isTPSArtificiallyCapped == other.isTPSArtificiallyCapped && - isComplete == other.isComplete); + _tilesPerSecondLimit == other._tilesPerSecondLimit); @override int get hashCode => Object.hashAllUnordered([ - latestTileEvent, - cachedTiles, - cachedSize, - bufferedTiles, - bufferedSize, - skippedTiles, - skippedSize, - failedTiles, - maxTiles, + flushedTilesCount, + flushedTilesSize, + bufferedTilesCount, + bufferedTilesSize, + seaTilesCount, + seaTilesSize, + existingTilesCount, + existingTilesSize, + negativeResponseTilesCount, + failedRequestTilesCount, + retryTilesQueuedCount, + maxTilesCount, elapsedDuration, tilesPerSecond, - isTPSArtificiallyCapped, - isComplete, + _tilesPerSecondLimit, ]); } diff --git a/lib/src/bulk_download/external/tile_event.dart b/lib/src/bulk_download/external/tile_event.dart index acf26fe0..b4670f19 100644 --- a/lib/src/bulk_download/external/tile_event.dart +++ b/lib/src/bulk_download/external/tile_event.dart @@ -3,177 +3,201 @@ part of '../../../flutter_map_tile_caching.dart'; -/// A generalized category for [TileEventResult] -enum TileEventResultCategory { - /// The associated tile has been successfully downloaded and cached - /// - /// Independent category for [TileEventResult.success] only. - cached, - - /// The associated tile may have been downloaded, but was not cached - /// - /// This may be because it: - /// - already existed & `skipExistingTiles` was `true`: - /// [TileEventResult.alreadyExisting] - /// - was a sea tile & `skipSeaTiles` was `true`: [TileEventResult.isSeaTile] - skipped, - - /// The associated tile was not successfully downloaded, potentially for a - /// variety of reasons - /// - /// Category for [TileEventResult.negativeFetchResponse], - /// [TileEventResult.noConnectionDuringFetch], and - /// [TileEventResult.unknownFetchException]. - failed; -} - -/// The result of attempting to cache the associated tile/[TileEvent] -enum TileEventResult { - /// The associated tile was successfully downloaded and cached - success(TileEventResultCategory.cached), - - /// The associated tile was not downloaded (intentionally), becuase it already - /// existed & `skipExistingTiles` was `true` - alreadyExisting(TileEventResultCategory.skipped), - - /// The associated tile was downloaded, but was not cached (intentionally), - /// because it was a sea tile & `skipSeaTiles` was `true` - isSeaTile(TileEventResultCategory.skipped), - - /// The associated tile was not successfully downloaded because the tile server - /// responded with a status code other than HTTP 200 OK - negativeFetchResponse(TileEventResultCategory.failed), - - /// The associated tile was not successfully downloaded because a connection - /// could not be made to the tile server - noConnectionDuringFetch(TileEventResultCategory.failed), - - /// The associated tile was not successfully downloaded because of an unknown - /// exception when fetching the tile from the tile server - unknownFetchException(TileEventResultCategory.failed); - - /// The result of attempting to cache the associated tile/[TileEvent] - const TileEventResult(this.category); - - /// A generalized category for this event - final TileEventResultCategory category; -} - -/// The raw result of a tile download during bulk downloading +/// The result of a tile download during bulk downloading /// /// Does not contain information about the download as a whole, that is -/// [DownloadProgress]' responsibility. +/// [DownloadProgress]' scope. /// -/// {@template fmtc.tileevent.extraConsiderations} -/// > [!IMPORTANT] -/// > When tracking [TileEvent]s across multiple [DownloadProgress] events, -/// > extra considerations are necessary. See -/// > [the documentation](https://fmtc.jaffaketchup.dev/bulk-downloading/start#keeping-track-across-events) -/// > for more information. -/// {@endtemplate} +/// See specific subclasses for more information about the event. This is a +/// sealed tree, so there are a guaranteed knowable set of results. @immutable -class TileEvent { - const TileEvent._( - this.result, { +sealed class TileEvent { + const TileEvent._({ required this.url, required this.coordinates, - this.tileImage, - this.fetchResponse, - this.fetchError, - this.isRepeat = false, - bool wasBufferReset = false, - }) : _wasBufferReset = wasBufferReset; - - /// The status of this event, the result of attempting to cache this tile - /// - /// See [TileEventResult.category] ([TileEventResultCategory]) for - /// categorization of this result into 3 categories: - /// - /// - [TileEventResultCategory.cached] (tile was downloaded and cached) - /// - [TileEventResultCategory.skipped] (tile was not cached, but intentionally) - /// - [TileEventResultCategory.failed] (tile was not cached, due to an error) - /// - /// Remember to check [isRepeat] before keeping track of this value. - final TileEventResult result; + required this.wasRetryAttempt, + }); /// The URL used to request the tile - /// - /// Remember to check [isRepeat] before keeping track of this value. final String url; /// The (x, y, z) coordinates of this tile - /// - /// Remember to check [isRepeat] before keeping track of this value. - final TileCoordinates coordinates; - - /// The raw bytes that were fetched from the [url], if available - /// - /// Not available if the result category is [TileEventResultCategory.failed]. - /// - /// Remember to check [isRepeat] before keeping track of this value. - final Uint8List? tileImage; - - /// The raw [Response] from the HTTP GET request to [url], if available - /// - /// Not available if [result] is [TileEventResult.noConnectionDuringFetch], - /// [TileEventResult.unknownFetchException], or - /// [TileEventResult.alreadyExisting]. - /// - /// Remember to check [isRepeat] before keeping track of this value. - final Response? fetchResponse; + final (int, int, int) coordinates; - /// The raw error thrown when fetching from the [url], if available + /// Whether this tile was a retry attempt of a [FailedRequestTileEvent] /// - /// Only available if [result] is [TileEventResult.noConnectionDuringFetch] or - /// [TileEventResult.unknownFetchException]. + /// Never set if `retryFailedRequestTiles` is disabled. /// - /// Remember to check [isRepeat] before keeping track of this value. - final Object? fetchError; - - /// Whether this event is a repeat of the last event + /// Implies that the tile has been emitted before. Care should be taken to + /// ensure that this does not cause issues (for example, duplication issues). /// - /// Events will occasionally be repeated due to the `maxReportInterval` - /// functionality. If using other members, such as [result], to keep count of - /// important events, do not count an event where this is `true`. - /// - /// {@macro fmtc.tileevent.extraConsiderations} - final bool isRepeat; - - final bool _wasBufferReset; - - TileEvent _copyWithRepeat() => TileEvent._( - result, - url: url, - coordinates: coordinates, - tileImage: tileImage, - fetchResponse: fetchResponse, - fetchError: fetchError, - isRepeat: true, - wasBufferReset: _wasBufferReset, - ); + /// (This is also used internally to maintain [DownloadProgress] statistics.) + final bool wasRetryAttempt; @override bool operator ==(Object other) => identical(this, other) || (other is TileEvent && - result == other.result && url == other.url && coordinates == other.coordinates && - tileImage == other.tileImage && - fetchResponse == other.fetchResponse && - fetchError == other.fetchError && - isRepeat == other.isRepeat && - _wasBufferReset == other._wasBufferReset); + wasRetryAttempt == other.wasRetryAttempt); + + @override + int get hashCode => Object.hashAllUnordered([url, coordinates]); +} + +/// The raw result of a successful tile download during bulk downloading +/// +/// Successful means the tile was requested from the [url] and recieved an HTTP +/// response of 200 OK, with an image as the body. +@immutable +class SuccessfulTileEvent extends TileEvent + with TileEventFetchResponse, TileEventImage { + const SuccessfulTileEvent._({ + required super.url, + required super.coordinates, + required super.wasRetryAttempt, + required this.tileImage, + required this.fetchResponse, + required bool wasBufferFlushed, + }) : _wasBufferFlushed = wasBufferFlushed, + super._(); + + @override + final Uint8List tileImage; + + @override + final Response fetchResponse; + + /// Whether this tile triggered the internal bulk download buffer to be + /// flushed + /// + /// There is one buffer per download thread, with the `maxBufferLength` being + /// shared evenly to all threads. This indication is only applicable for the + /// thread from which this event was generated (and is therefore not suitable + /// for public exposure). + final bool _wasBufferFlushed; +} + +/// The raw result of a skipped tile download during bulk downloading +/// +/// Skipped means the request to the [url] was not made. See subclasses for +/// specific skip reasons. +@immutable +sealed class SkippedTileEvent extends TileEvent with TileEventImage { + const SkippedTileEvent._({ + required super.url, + required super.coordinates, + required super.wasRetryAttempt, + required this.tileImage, + }) : super._(); + + @override + final Uint8List tileImage; +} + +/// The raw result of an existing tile download during bulk downloading +/// +/// Existing means the request to the [url] was not made because the tile +/// already existed and `skipExistingTiles` was enabled. +/// +/// This implies the tile cannot be retry attempt (as tiles in this category are +/// never retried because they can never fail due to a previous +/// [FailedRequestTileEvent]). +@immutable +class ExistingTileEvent extends SkippedTileEvent { + const ExistingTileEvent._({ + required super.url, + required super.coordinates, + required super.tileImage, + }) : super._(wasRetryAttempt: false); +} + +/// The raw result of a sea tile download during bulk downloading +/// +/// Sea means the request to [url] was made, and a response was recieved, but +/// the tile image was determined to be a sea tile and `skipSeaTiles` was +/// enabled. +@immutable +class SeaTileEvent extends SkippedTileEvent with TileEventFetchResponse { + const SeaTileEvent._({ + required super.url, + required super.coordinates, + required super.wasRetryAttempt, + required super.tileImage, + required this.fetchResponse, + }) : super._(); + + @override + final Response fetchResponse; +} + +/// The raw result of a failed tile download during bulk downloading +/// +/// Failed means a request to [url] was attempted, but a HTTP 200 OK response +/// was not recieved. See subclasses for specific failure reasons. +@immutable +sealed class FailedTileEvent extends TileEvent { + const FailedTileEvent._({ + required super.url, + required super.coordinates, + required super.wasRetryAttempt, + }) : super._(); +} + +/// The raw result of a negative response tile download during bulk downloading +/// +/// Negative response means the request to the [url] was made successfully, but +/// a HTTP 200 OK response was not received. +@immutable +class NegativeResponseTileEvent extends FailedTileEvent + with TileEventFetchResponse { + const NegativeResponseTileEvent._({ + required super.url, + required super.coordinates, + required super.wasRetryAttempt, + required this.fetchResponse, + }) : super._(); @override - int get hashCode => Object.hashAllUnordered([ - result, - url, - coordinates, - tileImage, - fetchResponse, - fetchError, - isRepeat, - _wasBufferReset, - ]); + final Response fetchResponse; +} + +/// The raw result of a failed request tile download during bulk downloading +/// +/// Failed request means the request to the [url] was not made successfully +/// (likely due to a network issue). +/// +/// This tile will be added to the retry queue if `retryFailedRequestTiles` is +/// enabled, and it was not already a retry attempt ([wasRetryAttempt]). +@immutable +class FailedRequestTileEvent extends FailedTileEvent { + const FailedRequestTileEvent._({ + required super.url, + required super.coordinates, + required super.wasRetryAttempt, + required this.fetchError, + }) : super._(); + + /// The raw error thrown when attempting to make a HTTP request to [url] + final Object fetchError; +} + +/// Indicates a [TileEvent] recieved a HTTP response from the [TileEvent.url] +/// +/// The status code may or may not be 200 OK: this does not imply whether the +/// event was successful or not. +mixin TileEventFetchResponse on TileEvent { + /// The raw HTTP response from the GET request to [url] + abstract final Response fetchResponse; +} + +/// Indicates a [TileEvent] has an associated tile image +/// +/// This may be from a successful HTTP response from [TileEvent.url], or it may +/// be retrieved from the cache: this does not imply whether the event was +/// successful or skipped, but it does imply it was not a failure. +mixin TileEventImage on TileEvent { + /// The raw bytes associated with the [url]/[coordinates] + abstract final Uint8List tileImage; } diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index 610e2a37..eb1b44da 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -14,6 +14,7 @@ Future _downloadManager( bool skipSeaTiles, Duration? maxReportInterval, int? rateLimit, + bool retryFailedRequestTiles, String Function(String) urlTransformer, int? recoveryId, FMTCBackendInternalThreadSafe backend, @@ -71,9 +72,8 @@ Future _downloadManager( threadBuffersTiles = List.filled(input.parallelThreads, 0); } - // Setup tile generator isolate + // Setup tile generation final tileReceivePort = ReceivePort(); - final tileIsolate = await Isolate.spawn( (({SendPort sendPort, DownloadableRegion region}) input) => input.region.when( @@ -98,10 +98,44 @@ Future _downloadManager( debugName: '[FMTC] Tile Coords Generator Thread', ); + // Setup retry tile utils + final retryTiles = <(int, int, int)>[]; + bool isRetryingTiles = false; + (int, int, int)? lastTileRetry; // See explanation below + + // Merge generated and retry tile streams together + final mergedTileStreams = () async* { + // First, output the generated tile stream + // This stream only emits events when a tile is requested, to minimize + // memory consumption + await for (final evt in tileReceivePort) { + if (evt == null) break; + yield evt; + } + + // After there are no more new tiles available, emit retry tiles if + // necessary + // We must store these coordinates in memory, so there is no use + // implementing a request/recieve system as above + // We send the retry tiles through this path to ensure they are rate limited + if (retryTiles.isEmpty) return; + assert( + input.retryFailedRequestTiles, + 'Should not record tiles for retry when disabled', + ); + // We set a flag, so threads are aware, so `TileEvent`s are aware, so stats + // are aware + isRetryingTiles = true; + yield* Stream.fromIterable(retryTiles); // Must not modify during streaming + // We cannot add events to the list of tiles during its streaming, so we + // make a special place to store the the potential failure of the last + // fresh tile + if (lastTileRetry != null) yield lastTileRetry; + }(); final tileQueue = StreamQueue( input.rateLimit == null - ? tileReceivePort - : tileReceivePort.rateLimit( + ? mergedTileStreams + : mergedTileStreams.rateLimit( minimumSpacing: Duration( microseconds: ((1 / input.rateLimit!) * 1000000).ceil(), ), @@ -110,7 +144,11 @@ Future _downloadManager( final requestTilePort = await tileQueue.next as SendPort; // Start progress tracking - final initialDownloadProgress = DownloadProgress._initial(maxTiles: maxTiles); + final initialDownloadProgress = DownloadProgress._initial( + maxTilesCount: maxTiles, + tilesPerSecondLimit: input.rateLimit, + retryFailedRequestTiles: input.retryFailedRequestTiles, + ); var lastDownloadProgress = initialDownloadProgress; final downloadDuration = Stopwatch(); final tileCompletionTimestamps = []; @@ -134,7 +172,7 @@ Future _downloadManager( // Setup two-way communications with root final rootReceivePort = ReceivePort(); - void send(Object? m) => input.sendPort.send(m); + void sendToMain(Object? m) => input.sendPort.send(m); // Setup cancel, pause, and resume handling Iterable> generateThreadPausedStates() => Iterable.generate( @@ -157,7 +195,7 @@ Future _downloadManager( threadPausedStates.setAll(0, generateThreadPausedStates()); await Future.wait(threadPausedStates.map((e) => e.future)); downloadDuration.stop(); - send(_DownloadManagerControlCmd.pause); + sendToMain(_DownloadManagerControlCmd.pause); case _DownloadManagerControlCmd.resume: pauseResumeSignal.complete(); downloadDuration.start(); @@ -175,12 +213,10 @@ Future _downloadManager( (_) { if (lastDownloadProgress != initialDownloadProgress && pauseResumeSignal.isCompleted) { - send( - lastDownloadProgress = - lastDownloadProgress._fallbackReportUpdate( - newDuration: downloadDuration.elapsed, + sendToMain( + lastDownloadProgress = lastDownloadProgress._updateWithoutTile( + elapsedDuration: downloadDuration.elapsed, tilesPerSecond: getCurrentTPS(registerNewTPS: false), - rateLimit: input.rateLimit, ), ); } @@ -199,13 +235,11 @@ Future _downloadManager( } // Create convienience method to update recovery system if enabled - void updateRecoveryIfNecessary() { + void updateRecovery() { if (input.recoveryId case final recoveryId?) { input.backend.updateRecovery( id: recoveryId, - newStartTile: 1 + - (lastDownloadProgress.cachedTiles - - lastDownloadProgress.bufferedTiles), + newStartTile: 1 + lastDownloadProgress.flushedTilesCount, ); } } @@ -214,10 +248,10 @@ Future _downloadManager( final threadBackend = input.backend.duplicate(); // Now it's safe, start accepting communications from the root - send(rootReceivePort.sendPort); + sendToMain(rootReceivePort.sendPort); // Send an initial progress report to indicate the start of the download - send(initialDownloadProgress); + sendToMain(initialDownloadProgress); // Start download threads & wait for download to complete/cancelled downloadDuration.start(); @@ -255,38 +289,75 @@ Future _downloadManager( // kill all threads unawaited( cancelSignal.future + // Handles case when cancel is emitted before thread is setup .then((_) => sendPortCompleter.future) - .then((sp) => sp.send(null)), + .then((s) => s.send(null)), ); downloadThreadReceivePort.listen( (evt) async { // Thread is sending tile data if (evt is TileEvent) { + // Send event to user + sendToMain(evt); + + // Queue tiles for retry if failed and not already a retry attempt + if (input.retryFailedRequestTiles && + evt is FailedRequestTileEvent && + !evt.wasRetryAttempt) { + if (isRetryingTiles) { + assert( + lastTileRetry == null, + 'Must not already have a recorded last tile', + ); + lastTileRetry = evt.coordinates; + } else { + retryTiles.add(evt.coordinates); + } + } + // If buffering is in use, send a progress update with buffer info if (input.maxBufferLength != 0) { + // TODO: Fix incorrect stats reporting + // Update correct thread buffer with new tile on success - if (evt.result == TileEventResult.success) { - if (evt._wasBufferReset) { - threadBuffersSize[threadNo] = 0; + late final int flushedTilesCount; + late final int flushedTilesSize; + if (evt is SuccessfulTileEvent) { + if (evt._wasBufferFlushed) { + flushedTilesCount = threadBuffersTiles[threadNo]; + flushedTilesSize = threadBuffersSize[threadNo]; threadBuffersTiles[threadNo] = 0; + threadBuffersSize[threadNo] = 0; } else { - threadBuffersSize[threadNo] += evt.tileImage!.lengthInBytes; threadBuffersTiles[threadNo]++; + threadBuffersSize[threadNo] += evt.tileImage.lengthInBytes; } } - send( - lastDownloadProgress = - lastDownloadProgress._updateProgressWithTile( + final wasBufferFlushed = + evt is SuccessfulTileEvent && evt._wasBufferFlushed; + + sendToMain( + lastDownloadProgress = lastDownloadProgress._updateWithTile( newTileEvent: evt, - newBufferedTiles: - threadBuffersTiles.reduce((a, b) => a + b), - newBufferedSize: - threadBuffersSize.reduce((a, b) => a + b) / 1024, - newDuration: downloadDuration.elapsed, + bufferedTiles: evt is SuccessfulTileEvent + ? ( + count: threadBuffersTiles.reduce((a, b) => a + b), + size: threadBuffersSize.reduce((a, b) => a + b) / + 1024, + ) + : null, + flushedTiles: wasBufferFlushed + ? ( + count: lastDownloadProgress.flushedTilesCount + + flushedTilesCount, + size: lastDownloadProgress.flushedTilesSize + + flushedTilesSize / 1024 + ) + : null, + elapsedDuration: downloadDuration.elapsed, tilesPerSecond: getCurrentTPS(registerNewTPS: true), - rateLimit: input.rateLimit, ), ); @@ -295,21 +366,26 @@ Future _downloadManager( // We don't want to update recovery to a tile that isn't cached // (only buffered), because they'll be lost in the events // recovery is designed to recover from - if (evt._wasBufferReset) updateRecoveryIfNecessary(); + if (wasBufferFlushed) updateRecovery(); } else { - send( - lastDownloadProgress = - lastDownloadProgress._updateProgressWithTile( + // We do not need to care about buffering, which makes updates + // much easier + sendToMain( + lastDownloadProgress = lastDownloadProgress._updateWithTile( newTileEvent: evt, - newBufferedTiles: 0, - newBufferedSize: 0, - newDuration: downloadDuration.elapsed, + flushedTiles: evt is SuccessfulTileEvent + ? ( + count: lastDownloadProgress.flushedTilesCount + 1, + size: lastDownloadProgress.flushedTilesSize + + (evt.tileImage.lengthInBytes / 1024) + ) + : null, + elapsedDuration: downloadDuration.elapsed, tilesPerSecond: getCurrentTPS(registerNewTPS: true), - rateLimit: input.rateLimit, ), ); - updateRecoveryIfNecessary(); + updateRecovery(); } return; @@ -317,19 +393,31 @@ Future _downloadManager( // Thread is requesting new tile coords if (evt is int) { + // If pause requested, mark thread as paused and wait for resume if (!pauseResumeSignal.isCompleted) { threadPausedStates[threadNo].complete(); await pauseResumeSignal.future; } + // Request a new tile coord fresh from the generator + // This is only necessary if we are not retrying tiles, but we + // just attempt anyway requestTilePort.send(null); - try { - sendPort.send(await tileQueue.next); - // ignore: avoid_catching_errors - } on StateError { - sendPort.send(null); - } - return; + + // Wait for a tile coordinate to be generated if available + final nextCoordinates = (await tileQueue.take(1)).firstOrNull; + + // Kill the thread if no new tiles are available + if (nextCoordinates == null) return sendPort.send(null); + + // Otherwise, send the coordinate to the thread, marking whether + // it is a retry tile + return sendPort.send( + ( + tileCoordinates: nextCoordinates, + isRetryAttempt: isRetryingTiles, + ), + ); } // Thread is establishing comms @@ -360,21 +448,8 @@ Future _downloadManager( ); downloadDuration.stop(); - // Send final buffer cleared progress report - fallbackReportTimer?.cancel(); - send( - lastDownloadProgress = lastDownloadProgress._updateProgressWithTile( - newTileEvent: null, - newBufferedTiles: 0, - newBufferedSize: 0, - newDuration: downloadDuration.elapsed, - tilesPerSecond: 0, - rateLimit: input.rateLimit, - isComplete: true, - ), - ); - // Cleanup resources and shutdown + fallbackReportTimer?.cancel(); rootReceivePort.close(); if (input.recoveryId != null) await input.backend.uninitialise(); tileIsolate.kill(priority: Isolate.immediate); diff --git a/lib/src/bulk_download/internal/thread.dart b/lib/src/bulk_download/internal/thread.dart index 58ec75a7..af25a44c 100644 --- a/lib/src/bulk_download/internal/thread.dart +++ b/lib/src/bulk_download/internal/thread.dart @@ -34,12 +34,15 @@ Future _singleDownloadThread( await input.backend.initialise(); while (true) { - // Request new tile coords + // Request new data from manager send(0); - final rawCoords = (await tileQueue.next) as (int, int, int)?; + final managerInput = (await tileQueue.next) as ({ + (int, int, int) tileCoordinates, + bool isRetryAttempt, + })?; - // Cleanup resources and shutdown if no more coords available - if (rawCoords == null) { + // Cleanup resources and shutdown if no more data available + if (managerInput == null) { receivePort.close(); await tileQueue.cancel(immediate: true); @@ -58,31 +61,38 @@ Future _singleDownloadThread( Isolate.exit(); } - // Generate `TileCoordinates` - final coordinates = - TileCoordinates(rawCoords.$1, rawCoords.$2, rawCoords.$3); + // Destructure data from manager + final (:tileCoordinates, :isRetryAttempt) = managerInput; - // Get new tile URL & any existing tile - final networkUrl = - input.options.tileProvider.getTileUrl(coordinates, input.options); - final matcherUrl = input.urlTransformer(networkUrl); - - final existingTile = await input.backend.readTile( - url: matcherUrl, - storeName: input.storeName, + // Get new tile URLs + final networkUrl = input.options.tileProvider.getTileUrl( + TileCoordinates( + tileCoordinates.$1, + tileCoordinates.$2, + tileCoordinates.$3, + ), + input.options, ); + final matcherUrl = input.urlTransformer(networkUrl); - // Skip if tile already exists and user demands existing tile pruning - if (input.skipExistingTiles && existingTile != null) { - send( - TileEvent._( - TileEventResult.alreadyExisting, - url: networkUrl, - coordinates: coordinates, - tileImage: Uint8List.fromList(existingTile.bytes), - ), - ); - continue; + // If skipping existing tile, perform extra checks + if (input.skipExistingTiles) { + if ((await input.backend.readTile( + url: matcherUrl, + storeName: input.storeName, + )) + ?.bytes + case final bytes?) { + send( + ExistingTileEvent._( + url: networkUrl, + coordinates: tileCoordinates, + tileImage: Uint8List.fromList(bytes), + // Never a retry attempt + ), + ); + continue; + } } // Fetch new tile from URL @@ -90,15 +100,13 @@ Future _singleDownloadThread( try { response = await httpClient.get(Uri.parse(networkUrl), headers: input.headers); - } catch (e) { + } catch (err) { send( - TileEvent._( - e is SocketException - ? TileEventResult.noConnectionDuringFetch - : TileEventResult.unknownFetchException, + FailedRequestTileEvent._( url: networkUrl, - coordinates: coordinates, - fetchError: e, + coordinates: tileCoordinates, + fetchError: err, + wasRetryAttempt: isRetryAttempt, ), ); continue; @@ -106,11 +114,11 @@ Future _singleDownloadThread( if (response.statusCode != 200) { send( - TileEvent._( - TileEventResult.negativeFetchResponse, + NegativeResponseTileEvent._( url: networkUrl, - coordinates: coordinates, + coordinates: tileCoordinates, fetchResponse: response, + wasRetryAttempt: isRetryAttempt, ), ); continue; @@ -119,12 +127,12 @@ Future _singleDownloadThread( // Skip if tile is a sea tile & user demands sea tile pruning if (const ListEquality().equals(response.bodyBytes, input.seaTileBytes)) { send( - TileEvent._( - TileEventResult.isSeaTile, + SeaTileEvent._( url: networkUrl, - coordinates: coordinates, + coordinates: tileCoordinates, tileImage: response.bodyBytes, fetchResponse: response, + wasRetryAttempt: isRetryAttempt, ), ); continue; @@ -143,8 +151,10 @@ Future _singleDownloadThread( } // Write buffer to database if necessary - final wasBufferReset = tileUrlsBuffer.length >= input.maxBufferLength; - if (wasBufferReset) { + // Must set flag appropriately to indicate to manager whether the buffer + // stats and counters should be reset + final wasBufferFlushed = tileUrlsBuffer.length >= input.maxBufferLength; + if (wasBufferFlushed) { await input.backend.writeTiles( storeName: input.storeName, urls: tileUrlsBuffer, @@ -156,13 +166,13 @@ Future _singleDownloadThread( // Return successful response to user send( - TileEvent._( - TileEventResult.success, + SuccessfulTileEvent._( url: networkUrl, - coordinates: coordinates, + coordinates: tileCoordinates, tileImage: response.bodyBytes, fetchResponse: response, - wasBufferReset: wasBufferReset, + wasBufferFlushed: wasBufferFlushed, + wasRetryAttempt: isRetryAttempt, ), ); } diff --git a/lib/src/regions/recovered_region.dart b/lib/src/regions/recovered_region.dart index 0bd18139..d8a7c9d9 100644 --- a/lib/src/regions/recovered_region.dart +++ b/lib/src/regions/recovered_region.dart @@ -46,7 +46,7 @@ class RecoveredRegion { /// Corresponds to [DownloadableRegion.end] /// /// If originally created as `null`, this will be the number of tiles in the - /// region, as determined by [StoreDownload.check]. + /// region, as determined by [StoreDownload.countTiles]. final int end; /// The [BaseRegion] which was recovered diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 1b861013..99d3378d 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -36,18 +36,30 @@ class StoreDownload { /// /// > [!TIP] /// > To count the number of tiles in a region before starting a download, use - /// > [check]. + /// > [countTiles]. /// - /// Streams a [DownloadProgress] object containing statistics and information - /// about the download's progression status, once per tile and at intervals - /// of no longer than [maxReportInterval]. + /// --- + /// + /// Outputs two non-broadcast streams. + /// + /// One emits [DownloadProgress]s which contain stats and info about the whole + /// download. + /// + /// One emits [TileEvent]s which contain info about the most recent tile + /// attempted only. /// - /// The first event reports the download has started (setup is complete). The - /// last event indicates the download has completed (either sucessfully or - /// been cancelled). All events between these two emissions will be reporting - /// a new tile, or reporting the old tile, due to [maxReportInterval]. - /// See the documentation on [DownloadProgress.latestTileEvent] & - /// [TileEvent.isRepeat] for more details. + /// The first stream will emit events once per tile emitted on the second + /// stream, at intervals of no longer than [maxReportInterval], and once at + /// the start of the download indicating setup is complete and the first tile + /// is being downloaded. It finishes once the download is complete, with the + /// last event representing the last emitted tile event on the second stream. + /// + /// Neither output stream respects listen, pause, resume, or cancel events + /// when submitted through the stream subscription. + /// The download will start when this method is invoked, irrespective of + /// whether there are listeners. The download will continue irrespective of + /// listeners. The only control methods are via FMTC's [pause], [resume], and + /// [cancel] methods. /// /// --- /// @@ -93,6 +105,17 @@ class StoreDownload { /// /// --- /// + /// If [retryFailedRequestTiles] is enabled (as is by default), tiles that + /// fail to download due to a failed request ONLY ([FailedRequestTileEvent]) + /// will be queued and retried once after all remaining tiles have been + /// attempted. + /// This does not retry tiles that failed under [NegativeResponseTileEvent], + /// as the response from the server in these cases will likely indicate that + /// the issue is unlikely to be resolved shortly enough for a retry to succeed + /// (for example, 404 Not Found tiles are unlikely to ever exist). + /// + /// --- + /// /// A fresh [DownloadProgress] event will always be emitted every /// [maxReportInterval] (if specified), which defaults to every 1 second, /// regardless of whether any more tiles have been attempted/downloaded/failed. @@ -122,22 +145,25 @@ class StoreDownload { /// --- /// /// {@macro num_instances} - @useResult - Stream startForeground({ + ({ + Stream tileEvents, + Stream downloadProgress, + }) startForeground({ required DownloadableRegion region, int parallelThreads = 5, int maxBufferLength = 200, bool skipExistingTiles = false, bool skipSeaTiles = true, int? rateLimit, + bool retryFailedRequestTiles = true, Duration? maxReportInterval = const Duration(seconds: 1), bool disableRecovery = false, UrlTransformer? urlTransformer, Object instanceId = 0, - }) async* { + }) { FMTCBackendAccess.internal; // Verify initialisation - // Check input arguments for suitability + // Verify input arguments if (!(region.options.wmsOptions != null || region.options.urlTemplate != null)) { throw ArgumentError( @@ -196,76 +222,97 @@ class StoreDownload { : Object.hash(instanceId, DateTime.timestamp().millisecondsSinceEpoch); if (!disableRecovery) FMTCRoot.recovery._downloadsOngoing.add(recoveryId!); - // Start download thread - final receivePort = ReceivePort(); - await Isolate.spawn( - _downloadManager, - ( - sendPort: receivePort.sendPort, - region: region, - storeName: _storeName, - parallelThreads: parallelThreads, - maxBufferLength: maxBufferLength, - skipExistingTiles: skipExistingTiles, - skipSeaTiles: skipSeaTiles, - maxReportInterval: maxReportInterval, - rateLimit: rateLimit, - urlTransformer: resolvedUrlTransformer, - recoveryId: recoveryId, - backend: FMTCBackendAccessThreadSafe.internal, - ), - onExit: receivePort.sendPort, - debugName: '[FMTC] Master Bulk Download Thread', - ); + // Prepare output streams + final tileEventsStreamController = StreamController(); + final downloadProgressStreamController = + StreamController(); - // Setup control mechanisms (completers) - final cancelCompleter = Completer(); - Completer? pauseCompleter; + () async { + // Start download thread + final receivePort = ReceivePort(); + await Isolate.spawn( + _downloadManager, + ( + sendPort: receivePort.sendPort, + region: region, + storeName: _storeName, + parallelThreads: parallelThreads, + maxBufferLength: maxBufferLength, + skipExistingTiles: skipExistingTiles, + skipSeaTiles: skipSeaTiles, + maxReportInterval: maxReportInterval, + rateLimit: rateLimit, + retryFailedRequestTiles: retryFailedRequestTiles, + urlTransformer: resolvedUrlTransformer, + recoveryId: recoveryId, + backend: FMTCBackendAccessThreadSafe.internal, + ), + onExit: receivePort.sendPort, + debugName: '[FMTC] Master Bulk Download Thread', + ); - await for (final evt in receivePort) { - // Handle new progress message - if (evt is DownloadProgress) { - yield evt; - continue; - } + // Setup control mechanisms (completers) + final cancelCompleter = Completer(); + Completer? pauseCompleter; - // Handle pause comms - if (evt == _DownloadManagerControlCmd.pause) { - pauseCompleter?.complete(); - continue; - } + await for (final evt in receivePort) { + // Handle new download progress + if (evt is DownloadProgress) { + downloadProgressStreamController.add(evt); + continue; + } - // Handle shutdown (both normal and cancellation) - if (evt == null) break; + // Handle new tile event + if (evt is TileEvent) { + tileEventsStreamController.add(evt); + continue; + } - // Setup control mechanisms (senders) - if (evt is SendPort) { - instance - ..requestCancel = () { - evt.send(_DownloadManagerControlCmd.cancel); - return cancelCompleter.future; - } - ..requestPause = () { - evt.send(_DownloadManagerControlCmd.pause); - // Completed by handler above - return (pauseCompleter = Completer()).future - ..then((_) => instance.isPaused = true); - } - ..requestResume = () { - evt.send(_DownloadManagerControlCmd.resume); - instance.isPaused = false; - }; - continue; + // Handle pause comms + if (evt == _DownloadManagerControlCmd.pause) { + pauseCompleter?.complete(); + continue; + } + + // Handle shutdown (both normal and cancellation) + if (evt == null) break; + + // Setup control mechanisms (senders) + if (evt is SendPort) { + instance + ..requestCancel = () { + evt.send(_DownloadManagerControlCmd.cancel); + return cancelCompleter.future; + } + ..requestPause = () { + evt.send(_DownloadManagerControlCmd.pause); + // Completed by handler above + return (pauseCompleter = Completer()).future + ..then((_) => instance.isPaused = true); + } + ..requestResume = () { + evt.send(_DownloadManagerControlCmd.resume); + instance.isPaused = false; + }; + continue; + } + + throw UnimplementedError('Unrecognised message'); } - throw UnimplementedError('Unrecognised message'); - } + // Handle shutdown (both normal and cancellation) + receivePort.close(); + if (!disableRecovery) await FMTCRoot.recovery.cancel(recoveryId!); + DownloadInstance.unregister(instanceId); + cancelCompleter.complete(); + unawaited(tileEventsStreamController.close()); + unawaited(downloadProgressStreamController.close()); + }(); - // Handle shutdown (both normal and cancellation) - receivePort.close(); - if (!disableRecovery) await FMTCRoot.recovery.cancel(recoveryId!); - DownloadInstance.unregister(instanceId); - cancelCompleter.complete(); + return ( + tileEvents: tileEventsStreamController.stream, + downloadProgress: downloadProgressStreamController.stream, + ); } /// Count the number of tiles within the specified region @@ -275,7 +322,7 @@ class StoreDownload { /// /// Note that this does not require an existing/ready store, or a sensical /// [DownloadableRegion.options]. - Future check(DownloadableRegion region) => compute( + Future countTiles(DownloadableRegion region) => compute( (region) => region.when( rectangle: TileCounters.rectangleTiles, circle: TileCounters.circleTiles, @@ -286,6 +333,16 @@ class StoreDownload { region, ); + /// Count the number of tiles within the specified region + /// + /// This does not include skipped sea tiles or skipped existing tiles, as + /// those are handled during a download (as the contents must be known). + /// + /// Note that this does not require an existing/ready store, or a sensical + /// [DownloadableRegion.options]. + @Deprecated('`check` has been renamed to `countTiles`') + Future check(DownloadableRegion region) => countTiles(region); + /// Cancel the ongoing foreground download and recovery session /// /// Will return once the cancellation is complete. Note that all running From c08368314f75b5b6b2ef9d066de0257651a8dc8c Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 11 Dec 2024 22:27:59 +0000 Subject: [PATCH 71/97] Fixed `DownloadProgress` reporting bug when using buffering Updated CHANGELOG --- CHANGELOG.md | 9 ++- .../src/screens/main/map_view/map_view.dart | 12 ++-- .../components/slider_option.dart | 2 +- .../external/download_progress.dart | 65 ++++++++++++++----- lib/src/bulk_download/internal/manager.dart | 30 +++------ lib/src/store/download.dart | 15 +++-- 6 files changed, 78 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d4d6bba..fd91b56a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,11 +44,14 @@ Additionally, vector tiles are now supported in theory, as the internal caching/ * Improved speed (by massive amounts) and accuracy & reduced memory consumption of `CircleRegion`'s tile generation & counting algorithm * Deprecated `BaseRegion.(maybe)When` - this is easy to perform using a standard pattern-matched switch -* Changes to bulk downloading - * `DownloadProgress.latestTileEvent` is now nullable +* Major changes to bulk downloading + * Added support for retrying failed tiles (that failed because the request could not be made) once at the end of the download + * `StoreDownload.startForeground` output stream split into two streams returned as a record, one for `TileEvent`s, one for `DownloadProgress`s + * `TileEvents` has been split up into multiple classes and mixins to reduce nullability and uncertainty + * `DownloadProgress` has had its contained metrics changed to reflect the failed tiles retry, and `latestTileEvent` removed * Exporting stores is now more stable, and has improved documentation - The method now works in a dedicated temporary environment and attempts to perform two different strategies to move/copy-and-delete the result to the specified directory at the end before failing. Improved documentation covers the potential pitfalls of permissions and now recommends exporting to an app directory, then using the system share functionality on some devices. It now also returns the number of exported tiles. + > The method now works in a dedicated temporary environment and attempts to perform two different strategies to move/copy-and-delete the result to the specified directory at the end before failing. Improved documentation covers the potential pitfalls of permissions and now recommends exporting to an app directory, then using the system share functionality on some devices. It now also returns the number of exported tiles. * Removed deprecated remnants from v9.* 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 87762763..793119e8 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -1,4 +1,4 @@ -import 'dart:async'; +//import 'dart:async'; import 'dart:io'; import 'dart:math'; @@ -13,11 +13,11 @@ import 'package:provider/provider.dart'; import '../../../shared/misc/shared_preferences.dart'; import '../../../shared/misc/store_metadata_keys.dart'; -import '../../../shared/state/download_provider.dart'; +//import '../../../shared/state/download_provider.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/download_progress_masker.dart'; +//import 'components/download_progress/download_progress_masker.dart'; import 'components/fmtc_not_in_use_indicator.dart'; import 'components/region_selection/crosshairs.dart'; import 'components/region_selection/custom_polygon_snapping_indicator.dart'; @@ -329,9 +329,9 @@ class _MapViewState extends State with TickerProviderStateMixin { ), ); - final isDownloadProgressMaskerVisible = widget.mode == - MapViewMode.downloadRegion && - context.select((p) => p.isFocused); + //final isDownloadProgressMaskerVisible = widget.mode == + // MapViewMode.downloadRegion && + // context.select((p) => p.isFocused); final map = FlutterMap( mapController: _mapController.mapController, diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/slider_option.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/slider_option.dart index 1a7f042e..07691c0a 100644 --- a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/slider_option.dart +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/slider_option.dart @@ -34,7 +34,7 @@ class _SliderOption extends StatelessWidget { ), ), SizedBox( - width: 72, + width: 80, child: Text( '$value $descriptor', textAlign: TextAlign.end, diff --git a/lib/src/bulk_download/external/download_progress.dart b/lib/src/bulk_download/external/download_progress.dart index b2d663aa..f4e684d7 100644 --- a/lib/src/bulk_download/external/download_progress.dart +++ b/lib/src/bulk_download/external/download_progress.dart @@ -16,8 +16,8 @@ class DownloadProgress { /// update @protected const DownloadProgress._({ - required this.flushedTilesCount, - required this.flushedTilesSize, + required this.successfulTilesSize, + required this.successfulTilesCount, required this.bufferedTilesCount, required this.bufferedTilesSize, required this.seaTilesCount, @@ -44,8 +44,8 @@ class DownloadProgress { required this.maxTilesCount, required int? tilesPerSecondLimit, required bool retryFailedRequestTiles, - }) : flushedTilesCount = 0, - flushedTilesSize = 0, + }) : successfulTilesCount = 0, + successfulTilesSize = 0, bufferedTilesCount = 0, bufferedTilesSize = 0, seaTilesCount = 0, @@ -68,21 +68,24 @@ class DownloadProgress { /// /// For [SuccessfulTileEvent]s, the flushed and buffered metrics cannot be /// automatically updated from information in the tile event alone. - /// [flushedTiles] & [bufferedTiles] should be updated manually. + /// [bufferedTiles] should be updated manually. /// /// [maxTilesCount], [_tilesPerSecondLimit] & [_retryFailedRequestTiles] may /// not be modified. [elapsedDuration] & [tilesPerSecond] must always be /// modified. DownloadProgress _updateWithTile({ required TileEvent newTileEvent, - ({int count, double size})? flushedTiles, ({int count, double size})? bufferedTiles, required Duration elapsedDuration, required double tilesPerSecond, }) => DownloadProgress._( - flushedTilesCount: flushedTiles?.count ?? flushedTilesCount, - flushedTilesSize: flushedTiles?.size ?? flushedTilesSize, + successfulTilesCount: successfulTilesCount + + (newTileEvent is SuccessfulTileEvent ? 1 : 0), + successfulTilesSize: successfulTilesSize + + (newTileEvent is SuccessfulTileEvent + ? newTileEvent.tileImage.lengthInBytes / 1024 + : 0), bufferedTilesCount: bufferedTiles?.count ?? bufferedTilesCount, bufferedTilesSize: bufferedTiles?.size ?? bufferedTilesSize, seaTilesCount: seaTilesCount + (newTileEvent is SeaTileEvent ? 1 : 0), @@ -124,8 +127,8 @@ class DownloadProgress { required double tilesPerSecond, }) => DownloadProgress._( - flushedTilesCount: flushedTilesCount, - flushedTilesSize: flushedTilesSize, + successfulTilesCount: successfulTilesCount, + successfulTilesSize: successfulTilesSize, bufferedTilesCount: bufferedTilesCount, bufferedTilesSize: bufferedTilesSize, seaTilesCount: seaTilesCount, @@ -142,6 +145,32 @@ class DownloadProgress { retryFailedRequestTiles: _retryFailedRequestTiles, ); + /// Create a new progress object that represents a finished download + /// + /// This means [tilesPerSecond] is set to 0, and the buffered statistics are + /// set to 0. + DownloadProgress _updateToComplete({ + required Duration elapsedDuration, + }) => + DownloadProgress._( + successfulTilesCount: successfulTilesCount, + successfulTilesSize: successfulTilesSize, + bufferedTilesCount: 0, + bufferedTilesSize: 0, + seaTilesCount: seaTilesCount, + seaTilesSize: seaTilesSize, + existingTilesCount: existingTilesCount, + existingTilesSize: existingTilesSize, + negativeResponseTilesCount: negativeResponseTilesCount, + failedRequestTilesCount: failedRequestTilesCount, + retryTilesQueuedCount: retryTilesQueuedCount, + maxTilesCount: maxTilesCount, + elapsedDuration: elapsedDuration, + tilesPerSecond: 0, + tilesPerSecondLimit: _tilesPerSecondLimit, + retryFailedRequestTiles: _retryFailedRequestTiles, + ); + /// The number of tiles remaining to be attempted to download /// /// This includes [retryTilesQueuedCount] as tiles remaining. @@ -163,19 +192,19 @@ class DownloadProgress { /// and actually flushed/written to cache) /// /// This is the number of [SuccessfulTileEvent]s emitted. - int get successfulTilesCount => flushedTilesCount + bufferedTilesCount; + final int successfulTilesCount; /// The size in KiB of the tile images successfully downloaded (including both /// tiles buffered and actually flushed/written to cache) - double get successfulTilesSize => flushedTilesSize + bufferedTilesSize; + final double successfulTilesSize; /// The number of tiles successfully downloaded and written to the cache /// (flushed from the buffer) - final int flushedTilesCount; + int get flushedTilesCount => successfulTilesCount - bufferedTilesCount; /// The size in KiB of the tile images successfully downloaded and written to /// the cache (flushed from the buffer) - final double flushedTilesSize; + double get flushedTilesSize => successfulTilesSize - bufferedTilesSize; /// The number of tiles successfully downloaded but still to be written to the /// cache @@ -325,8 +354,8 @@ class DownloadProgress { bool operator ==(Object other) => identical(this, other) || (other is DownloadProgress && - flushedTilesCount == other.flushedTilesCount && - flushedTilesSize == other.flushedTilesSize && + successfulTilesCount == other.successfulTilesCount && + successfulTilesSize == other.successfulTilesSize && bufferedTilesCount == other.bufferedTilesCount && bufferedTilesSize == other.bufferedTilesSize && seaTilesCount == other.seaTilesCount && @@ -343,8 +372,8 @@ class DownloadProgress { @override int get hashCode => Object.hashAllUnordered([ - flushedTilesCount, - flushedTilesSize, + successfulTilesCount, + successfulTilesSize, bufferedTilesCount, bufferedTilesSize, seaTilesCount, diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index eb1b44da..3c2bacae 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -318,15 +318,9 @@ Future _downloadManager( // If buffering is in use, send a progress update with buffer info if (input.maxBufferLength != 0) { - // TODO: Fix incorrect stats reporting - // Update correct thread buffer with new tile on success - late final int flushedTilesCount; - late final int flushedTilesSize; if (evt is SuccessfulTileEvent) { if (evt._wasBufferFlushed) { - flushedTilesCount = threadBuffersTiles[threadNo]; - flushedTilesSize = threadBuffersSize[threadNo]; threadBuffersTiles[threadNo] = 0; threadBuffersSize[threadNo] = 0; } else { @@ -340,7 +334,6 @@ Future _downloadManager( sendToMain( lastDownloadProgress = lastDownloadProgress._updateWithTile( - newTileEvent: evt, bufferedTiles: evt is SuccessfulTileEvent ? ( count: threadBuffersTiles.reduce((a, b) => a + b), @@ -348,14 +341,7 @@ Future _downloadManager( 1024, ) : null, - flushedTiles: wasBufferFlushed - ? ( - count: lastDownloadProgress.flushedTilesCount + - flushedTilesCount, - size: lastDownloadProgress.flushedTilesSize + - flushedTilesSize / 1024 - ) - : null, + newTileEvent: evt, elapsedDuration: downloadDuration.elapsed, tilesPerSecond: getCurrentTPS(registerNewTPS: true), ), @@ -373,13 +359,6 @@ Future _downloadManager( sendToMain( lastDownloadProgress = lastDownloadProgress._updateWithTile( newTileEvent: evt, - flushedTiles: evt is SuccessfulTileEvent - ? ( - count: lastDownloadProgress.flushedTilesCount + 1, - size: lastDownloadProgress.flushedTilesSize + - (evt.tileImage.lengthInBytes / 1024) - ) - : null, elapsedDuration: downloadDuration.elapsed, tilesPerSecond: getCurrentTPS(registerNewTPS: true), ), @@ -446,7 +425,14 @@ Future _downloadManager( growable: false, ), ); + + // Send final progress update downloadDuration.stop(); + sendToMain( + lastDownloadProgress = lastDownloadProgress._updateToComplete( + elapsedDuration: downloadDuration.elapsed, + ), + ); // Cleanup resources and shutdown fallbackReportTimer?.cancel(); diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 99d3378d..bb0ca2eb 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -48,11 +48,16 @@ class StoreDownload { /// One emits [TileEvent]s which contain info about the most recent tile /// attempted only. /// - /// The first stream will emit events once per tile emitted on the second - /// stream, at intervals of no longer than [maxReportInterval], and once at - /// the start of the download indicating setup is complete and the first tile - /// is being downloaded. It finishes once the download is complete, with the - /// last event representing the last emitted tile event on the second stream. + /// The first stream (of [DownloadProgress]s) will emit events: + /// * once per [TileEvent] emitted on the second stream + /// * at intervals of no longer than [maxReportInterval] + /// * once at the start of the download indicating setup is complete and the + /// first tile is being downloaded + /// * once additionally at the end of the download after the last tile + /// setting some final statistics (such as tiles per second to 0) + /// + /// Once the stream of [DownloadProgress]s completes/finishes, the download + /// has stopped. /// /// Neither output stream respects listen, pause, resume, or cancel events /// when submitted through the stream subscription. From 5f70fe6fbd1e3318a9a341382b4eb27d3801a54d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 18 Dec 2024 13:32:46 +0100 Subject: [PATCH 72/97] Improved example app --- example/lib/main.dart | 2 +- .../src/screens/import/stages/selection.dart | 32 +-- example/lib/src/screens/main/main.dart | 81 ++++--- .../additional_overlay.dart | 131 ++++++++++++ .../fmtc_not_in_use_indicator.dart | 41 ++++ .../components/bottom_sheet_wrapper.dart | 4 +- .../components/fmtc_not_in_use_indicator.dart | 50 ----- .../src/screens/main/map_view/map_view.dart | 57 ++--- ...nload_configuration_view_bottom_sheet.dart | 14 -- .../components/export_stores/button.dart | 14 +- .../contents/home/home_view_bottom_sheet.dart | 2 - .../shape_selector/shape_selector.dart | 2 +- .../components/shared/to_config_method.dart | 17 +- .../sub_regions_list/sub_regions_list.dart | 5 +- .../region_selection_view_bottom_sheet.dart | 47 +++-- .../region_selection_view_side.dart | 2 +- .../layouts/bottom_sheet/bottom_sheet.dart | 197 +++++++----------- .../components/scrollable_provider.dart | 3 +- .../utils/bottom_sheet_top_spacer.dart | 47 ----- .../bottom_sheet/utils/tab_header.dart | 136 +++++++----- .../src/shared/components/url_selector.dart | 110 +++++----- example/pubspec.yaml | 22 +- 22 files changed, 552 insertions(+), 464 deletions(-) create mode 100644 example/lib/src/screens/main/map_view/components/additional_overlay/additional_overlay.dart create mode 100644 example/lib/src/screens/main/map_view/components/additional_overlay/fmtc_not_in_use_indicator.dart delete mode 100644 example/lib/src/screens/main/map_view/components/fmtc_not_in_use_indicator.dart delete mode 100644 example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 909b4434..ebf768d0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -70,7 +70,7 @@ class _AppContainer extends StatelessWidget { brightness: Brightness.light, useMaterial3: true, textTheme: GoogleFonts.ubuntuTextTheme(ThemeData.light().textTheme), - colorSchemeSeed: Colors.teal, + colorSchemeSeed: Colors.green, switchTheme: SwitchThemeData( thumbIcon: WidgetStateProperty.resolveWith( (states) => states.contains(WidgetState.selected) diff --git a/example/lib/src/screens/import/stages/selection.dart b/example/lib/src/screens/import/stages/selection.dart index 909a0f9c..ecb5cdc2 100644 --- a/example/lib/src/screens/import/stages/selection.dart +++ b/example/lib/src/screens/import/stages/selection.dart @@ -105,20 +105,24 @@ class _ImportSelectionStageState extends State { ), ), const SizedBox(width: 16), - IconButton.filled( - onPressed: selectedStores.isNotEmpty && - (conflictStrategy != ImportConflictStrategy.skip || - selectedStores - .whereNot( - (store) => widget.availableStores[store]!, - ) - .isNotEmpty) - ? () => widget.nextStage( - selectedStores, - conflictStrategy, - ) - : null, - icon: const Icon(Icons.file_open), + SizedBox( + height: 42, + child: FilledButton.icon( + onPressed: selectedStores.isNotEmpty && + (conflictStrategy != + ImportConflictStrategy.skip || + selectedStores + .whereNot( + (store) => + widget.availableStores[store]!, + ) + .isNotEmpty) + ? () => + widget.nextStage(selectedStores, conflictStrategy) + : null, + icon: const Icon(Icons.file_open), + label: const Text('Start Import'), + ), ), ], ), diff --git a/example/lib/src/screens/main/main.dart b/example/lib/src/screens/main/main.dart index 31fdc51a..92f680b4 100644 --- a/example/lib/src/screens/main/main.dart +++ b/example/lib/src/screens/main/main.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../shared/state/region_selection_provider.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'; import 'secondary_view/layouts/bottom_sheet/bottom_sheet.dart'; +import 'secondary_view/layouts/bottom_sheet/components/delayed_frame_attached_dependent_builder.dart'; import 'secondary_view/layouts/side/side.dart'; class MainScreen extends StatefulWidget { @@ -43,6 +47,39 @@ class _MainScreenState extends State { 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, + 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 [ @@ -64,42 +101,23 @@ class _MainScreenState extends State { ], onDestinationSelected: (i) { setState(() => selectedTab = i); - /*if (i == 0) { - final requiresExpanding = - bottomSheetOuterController.size < 0.3; - - if (selectedTab != 0) { - setState(() => selectedTab = 0); - if (requiresExpanding) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => bottomSheetOuterController.animateTo( - 0.3, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ), - ); - } - } else { - setState(() => selectedTab = i); - WidgetsBinding.instance.addPostFrameCallback( - (_) => bottomSheetOuterController.animateTo( - requiresExpanding ? 0.3 : 0, - duration: const Duration(milliseconds: 200), - curve: - requiresExpanding ? Curves.easeOut : Curves.easeIn, - ), - ); - } + if (i == 1) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => bottomSheetOuterController.animateTo( + 32 / constraints.maxHeight, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ), + ); } else { - setState(() => selectedTab = i); WidgetsBinding.instance.addPostFrameCallback( (_) => bottomSheetOuterController.animateTo( - 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeIn, + 0.3, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, ), ); - }*/ + } }, ), ); @@ -156,6 +174,7 @@ class _MainScreenState extends State { bottomLeft: Radius.circular(16), ), child: MapView( + bottomSheetOuterController: bottomSheetOuterController, mode: mapMode, layoutDirection: layoutDirection, ), diff --git a/example/lib/src/screens/main/map_view/components/additional_overlay/additional_overlay.dart b/example/lib/src/screens/main/map_view/components/additional_overlay/additional_overlay.dart new file mode 100644 index 00000000..213fb39d --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/additional_overlay/additional_overlay.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../shared/state/region_selection_provider.dart'; +import '../../../secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart'; +import '../../../secondary_view/layouts/bottom_sheet/components/delayed_frame_attached_dependent_builder.dart'; +import '../../map_view.dart'; +import 'fmtc_not_in_use_indicator.dart'; + +class AdditionalOverlay extends StatelessWidget { + const AdditionalOverlay({ + super.key, + required this.bottomSheetOuterController, + required this.layoutDirection, + required this.mode, + }); + + final DraggableScrollableController bottomSheetOuterController; + final Axis layoutDirection; + final MapViewMode mode; + + @override + Widget build(BuildContext context) { + final showShapeSelector = mode == MapViewMode.downloadRegion && + !context.read().isDownloadSetupPanelVisible; + + return AnimatedSlide( + offset: mode != MapViewMode.standard ? Offset.zero : const Offset(0, 1.1), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Align( + alignment: Alignment.centerRight, + child: FMTCNotInUseIndicator(mode: mode), + ), + if (layoutDirection == Axis.vertical) + SizedBox( + width: double.infinity, + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: DelayedControllerAttachmentBuilder( + listenable: bottomSheetOuterController, + builder: (context, child) { + if (!bottomSheetOuterController.isAttached) return child!; + return AnimatedBuilder( + animation: bottomSheetOuterController, + builder: (context, child) => _HeightZero( + useChildHeight: showShapeSelector && + bottomSheetOuterController.pixels <= 33, + child: child!, + ), + child: child, + ); + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(8), + margin: EdgeInsets.only( + bottom: 8 + + (context + .watch() + .constructedRegions + .isNotEmpty + ? 40 + : 0), + ), + child: const ShapeSelector(), + ), + ), + ), + ) + else + const SizedBox.shrink(), + ], + ), + ); + } +} + +class _HeightZeroRenderer extends RenderBox + with RenderObjectWithChildMixin, RenderProxyBoxMixin { + _HeightZeroRenderer({required bool useChildHeight}) + : _useChildHeight = useChildHeight; + + bool get useChildHeight => _useChildHeight; + bool _useChildHeight; + set useChildHeight(bool value) { + if (_useChildHeight != value) { + _useChildHeight = value; + markNeedsLayout(); + } + } + + @override + void performLayout() { + child!.layout(constraints, parentUsesSize: true); + size = Size( + child!.size.width, + useChildHeight ? child!.size.height : 0, + ); + } +} + +class _HeightZero extends SingleChildRenderObjectWidget { + const _HeightZero({ + this.useChildHeight = false, + required Widget super.child, + }); + + final bool useChildHeight; + + @override + RenderObject createRenderObject(BuildContext context) => + _HeightZeroRenderer(useChildHeight: useChildHeight); + + @override + void updateRenderObject( + BuildContext context, + _HeightZeroRenderer renderObject, + ) => + renderObject.useChildHeight = useChildHeight; +} diff --git a/example/lib/src/screens/main/map_view/components/additional_overlay/fmtc_not_in_use_indicator.dart b/example/lib/src/screens/main/map_view/components/additional_overlay/fmtc_not_in_use_indicator.dart new file mode 100644 index 00000000..d2f90a9c --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/additional_overlay/fmtc_not_in_use_indicator.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../map_view.dart'; + +class FMTCNotInUseIndicator extends StatelessWidget { + const FMTCNotInUseIndicator({ + super.key, + required this.mode, + }); + + final MapViewMode mode; + + @override + Widget build(BuildContext context) => IgnorePointer( + child: Opacity( + opacity: 2 / 3, + child: Container( + decoration: BoxDecoration( + color: Colors.amber, + borderRadius: BorderRadius.circular(99), + boxShadow: [ + BoxShadow( + color: Colors.grey.withAlpha(255 ~/ 2), + spreadRadius: 6, + blurRadius: 8, + ), + ], + ), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.hide_image), + SizedBox(width: 8), + Text('FMTC not in use in this view'), + ], + ), + ), + ), + ); +} diff --git a/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart b/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart index 4d841a2f..26164938 100644 --- a/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart +++ b/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart @@ -37,7 +37,8 @@ class _BottomSheetMapWrapperState extends State { // Introduce padding at the bottom of the screen to ensure that the // center of the map is affected by the bottom sheet, so the center // is always in the 'visible' center. - final screenPaddingTop = MediaQuery.paddingOf(context).top; + final screenPaddingTop = + MediaQueryData.fromView(View.of(context)).padding.top; return DelayedControllerAttachmentBuilder( listenable: widget.bottomSheetOuterController, @@ -72,6 +73,7 @@ class _BottomSheetMapWrapperState extends State { child: MapView( mode: widget.mode, layoutDirection: widget.layoutDirection, + bottomSheetOuterController: widget.bottomSheetOuterController, bottomPaddingWrapperBuilder: (context, child) { final useAssumedRadius = !widget.bottomSheetOuterController.isAttached || diff --git a/example/lib/src/screens/main/map_view/components/fmtc_not_in_use_indicator.dart b/example/lib/src/screens/main/map_view/components/fmtc_not_in_use_indicator.dart deleted file mode 100644 index 0c311037..00000000 --- a/example/lib/src/screens/main/map_view/components/fmtc_not_in_use_indicator.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../map_view.dart'; - -class FMTCNotInUseIndicator extends StatelessWidget { - const FMTCNotInUseIndicator({ - super.key, - required this.mode, - }); - - final MapViewMode mode; - - @override - Widget build(BuildContext context) => Opacity( - opacity: 0.8, - child: IgnorePointer( - child: AnimatedSlide( - offset: mode != MapViewMode.standard - ? Offset.zero - : const Offset(1.1, 0), - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: FittedBox( - child: Container( - decoration: BoxDecoration( - color: Colors.amber, - borderRadius: BorderRadius.circular(99), - boxShadow: [ - BoxShadow( - color: Colors.grey.withAlpha(255 ~/ 2), - spreadRadius: 6, - blurRadius: 8, - ), - ], - ), - padding: - const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: const Row( - children: [ - Icon(Icons.hide_image), - SizedBox(width: 8), - Text('FMTC not in use in this view'), - ], - ), - ), - ), - ), - ), - ); -} 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 793119e8..9fa5d60a 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -16,9 +16,9 @@ import '../../../shared/misc/store_metadata_keys.dart'; //import '../../../shared/state/download_provider.dart'; import '../../../shared/state/general_provider.dart'; 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/fmtc_not_in_use_indicator.dart'; import 'components/region_selection/crosshairs.dart'; import 'components/region_selection/custom_polygon_snapping_indicator.dart'; import 'components/region_selection/region_shape.dart'; @@ -34,12 +34,14 @@ class MapView extends StatefulWidget { this.mode = MapViewMode.standard, this.bottomPaddingWrapperBuilder, required this.layoutDirection, + required this.bottomSheetOuterController, }); final MapViewMode mode; final Widget Function(BuildContext context, Widget child)? bottomPaddingWrapperBuilder; final Axis layoutDirection; + final DraggableScrollableController bottomSheetOuterController; @override State createState() => _MapViewState(); @@ -67,7 +69,7 @@ class _MapViewState extends State with TickerProviderStateMixin { }, ).distinct(mapEquals); - bool _isInRegionSelectMode() => + bool get _isInRegionSelectMode => widget.mode == MapViewMode.downloadRegion && !context.read().isDownloadSetupPanelVisible; @@ -100,7 +102,7 @@ class _MapViewState extends State with TickerProviderStateMixin { keepAlive: true, backgroundColor: const Color(0xFFaad3df), onTap: (_, __) { - if (!_isInRegionSelectMode()) return; + if (!_isInRegionSelectMode) return; final provider = context.read(); @@ -144,15 +146,15 @@ class _MapViewState extends State with TickerProviderStateMixin { } }, onSecondaryTap: (_, __) { - if (!_isInRegionSelectMode()) return; + if (!_isInRegionSelectMode) return; context.read().removeLastCoordinate(); }, onLongPress: (_, __) { - if (!_isInRegionSelectMode()) return; + if (!_isInRegionSelectMode) return; context.read().removeLastCoordinate(); }, onPointerHover: (evt, point) { - if (!_isInRegionSelectMode()) return; + if (!_isInRegionSelectMode) return; final provider = context.read(); @@ -177,7 +179,7 @@ class _MapViewState extends State with TickerProviderStateMixin { } }, onPositionChanged: (position, _) { - if (!_isInRegionSelectMode()) return; + if (!_isInRegionSelectMode) return; final provider = context.read(); @@ -397,23 +399,30 @@ class _MapViewState extends State with TickerProviderStateMixin { child: map, ), if (isCrosshairsVisible) const Center(child: Crosshairs()), - if (widget.bottomPaddingWrapperBuilder case final bpwb?) - Positioned( - bottom: 8, - right: 8, - child: Builder( - builder: (context) => bpwb( - context, - FMTCNotInUseIndicator(mode: widget.mode), - ), - ), - ) - else - Positioned( - bottom: 16, - right: 16, - child: FMTCNotInUseIndicator(mode: widget.mode), - ), + Positioned( + bottom: 0, + right: 8, + left: 8, + child: widget.bottomPaddingWrapperBuilder != null + ? Builder( + builder: (context) => + widget.bottomPaddingWrapperBuilder!( + context, + AdditionalOverlay( + bottomSheetOuterController: + widget.bottomSheetOuterController, + layoutDirection: Axis.vertical, + mode: widget.mode, + ), + ), + ) + : AdditionalOverlay( + bottomSheetOuterController: + widget.bottomSheetOuterController, + layoutDirection: Axis.horizontal, + mode: widget.mode, + ), + ), ], ); }, diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart index e0e1920c..7f202016 100644 --- a/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; -import '../../layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart'; import '../../layouts/bottom_sheet/utils/tab_header.dart'; -import '../region_selection/components/shape_selector/shape_selector.dart'; -import '../region_selection/components/sub_regions_list/sub_regions_list.dart'; class DownloadConfigurationViewBottomSheet extends StatelessWidget { const DownloadConfigurationViewBottomSheet({super.key}); @@ -14,19 +11,8 @@ class DownloadConfigurationViewBottomSheet extends StatelessWidget { controller: BottomSheetScrollableProvider.innerScrollControllerOf(context), slivers: const [ - BottomSheetTopSpacer(), TabHeader(title: 'Download Configuration'), SliverToBoxAdapter(child: SizedBox(height: 6)), - SliverPadding( - padding: EdgeInsets.symmetric(horizontal: 16), - sliver: SliverToBoxAdapter( - child: ShapeSelector(), - ), - ), - SliverToBoxAdapter(child: Divider(height: 24)), - SliverToBoxAdapter(child: SizedBox(height: 6)), - SubRegionsList(), - SliverToBoxAdapter(child: SizedBox(height: 6)), ], ); } diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart index 4392c621..8a9112c1 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart @@ -133,15 +133,15 @@ class ExportStoresButton extends StatelessWidget { switch (selectedType) { case FileSystemEntityType.notFound: break; - case FileSystemEntityType.directory: - case FileSystemEntityType.link: - case FileSystemEntityType.pipe: - case FileSystemEntityType.unixDomainSock: - ScaffoldMessenger.maybeOf(context)?.showSnackBar(invalidTypeSnackbar); - return; case FileSystemEntityType.file: if ((Platform.isAndroid || Platform.isIOS) && - (await showOverwriteConfirmationDialog(context) ?? false)) return; + (await showOverwriteConfirmationDialog(context) ?? false)) { + return; + } + // ignore: no_default_cases + default: + ScaffoldMessenger.maybeOf(context)?.showSnackBar(invalidTypeSnackbar); + return; } filePath = intermediateFilePath; diff --git a/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart index febc3cf7..2455e59e 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; -import '../../layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart'; import '../../layouts/bottom_sheet/utils/tab_header.dart'; import 'components/map_configurator/map_configurator.dart'; import 'components/stores_list/stores_list.dart'; @@ -19,7 +18,6 @@ class _HomeViewBottomSheetState extends State { controller: BottomSheetScrollableProvider.innerScrollControllerOf(context), slivers: const [ - BottomSheetTopSpacer(), TabHeader(title: 'Stores & Config'), SliverToBoxAdapter(child: SizedBox(height: 6)), SliverPadding( diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart index 865ffa36..3734b4c6 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart @@ -247,7 +247,7 @@ class _ShapeSelectorState extends State { void _completeRegion() { _addSubRegion(); - moveToDownloadConfigView(context); + prepareDownloadConfigView(context); } void _addSubRegion() { 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 8150bd7c..00d6bcd9 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 @@ -5,7 +5,10 @@ import 'package:provider/provider.dart'; import '../../../../../../../shared/state/general_provider.dart'; import '../../../../../../../shared/state/region_selection_provider.dart'; -void moveToDownloadConfigView(BuildContext context) { +void prepareDownloadConfigView( + BuildContext context, { + bool shouldMoveTo = true, +}) { final regionSelectionProvider = context.read(); final bounds = LatLngBounds.fromPoints( @@ -21,9 +24,15 @@ void moveToDownloadConfigView(BuildContext context) { ); } context.read().animatedMapController.animatedFitCamera( - cameraFit: - CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(16)), + cameraFit: CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(16) + + MediaQueryData.fromView(View.of(context)).padding + + const EdgeInsets.only(bottom: 18), + ), ); - regionSelectionProvider.isDownloadSetupPanelVisible = true; + if (shouldMoveTo) { + regionSelectionProvider.isDownloadSetupPanelVisible = true; + } } diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/sub_regions_list.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/sub_regions_list.dart index 0055357b..a9bd51ee 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/sub_regions_list.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/sub_regions_list.dart @@ -55,7 +55,10 @@ class _SubRegionsListState extends State { bounds: LatLngBounds.fromPoints( region.toOutline().toList(), ), - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16) + + MediaQueryData.fromView(View.of(context)) + .padding + + const EdgeInsets.only(bottom: 18), ), ); }, diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart index aa14512f..91c7dc07 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart @@ -1,32 +1,37 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../../../shared/state/region_selection_provider.dart'; import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; -import '../../layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart'; import '../../layouts/bottom_sheet/utils/tab_header.dart'; -import 'components/shape_selector/shape_selector.dart'; +import 'components/sub_regions_list/components/no_sub_regions.dart'; import 'components/sub_regions_list/sub_regions_list.dart'; class RegionSelectionViewBottomSheet extends StatelessWidget { const RegionSelectionViewBottomSheet({super.key}); @override - Widget build(BuildContext context) => CustomScrollView( - controller: - BottomSheetScrollableProvider.innerScrollControllerOf(context), - slivers: const [ - BottomSheetTopSpacer(), - TabHeader(title: 'Download Selection'), - SliverToBoxAdapter(child: SizedBox(height: 6)), - SliverPadding( - padding: EdgeInsets.symmetric(horizontal: 16), - sliver: SliverToBoxAdapter( - child: ShapeSelector(), - ), - ), - SliverToBoxAdapter(child: Divider(height: 24)), - SliverToBoxAdapter(child: SizedBox(height: 6)), - SubRegionsList(), - SliverToBoxAdapter(child: SizedBox(height: 6)), - ], - ); + Widget build(BuildContext context) { + final hasConstructedRegions = context.select( + (p) => p.constructedRegions.isNotEmpty, + ); + + return CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + slivers: [ + const TabHeader(title: 'Download Selection'), + const SliverToBoxAdapter(child: SizedBox(height: 6)), + SliverPadding( + padding: hasConstructedRegions + ? const EdgeInsets.only(bottom: 16 + 52) + : EdgeInsets.zero, + sliver: hasConstructedRegions + ? const SubRegionsList() + : const NoSubRegions(), + ), + const SliverToBoxAdapter(child: SizedBox(height: 6)), + ], + ); + } } diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart index e89b6690..bd0de307 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart @@ -74,7 +74,7 @@ class RegionSelectionViewSide extends StatelessWidget { height: double.infinity, child: FilledButton.icon( onPressed: () => - moveToDownloadConfigView(context), + prepareDownloadConfigView(context), label: const Text('Configure Download'), icon: const Icon(Icons.tune), ), 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 adc17527..c6c83142 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 @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -27,140 +28,84 @@ class SecondaryViewBottomSheet extends StatefulWidget { class _SecondaryViewBottomSheetState extends State { @override - Widget build(BuildContext context) { - final screenTopPadding = - MediaQueryData.fromView(View.of(context)).padding.top; - - return LayoutBuilder( - builder: (context, constraints) => DraggableScrollableSheet( - initialChildSize: 0.3, - minChildSize: 0, - snap: true, - expand: false, - snapSizes: const [0.3], - controller: widget.controller, - builder: (context, innerController) => - DelayedControllerAttachmentBuilder( - listenable: widget.controller, - builder: (context, child) { - double radius = 18; - double calcHeight = 0; - - if (widget.controller.isAttached) { - final maxHeight = widget.controller.sizeToPixels(1); - - final oldValue = widget.controller.pixels; - final oldMax = maxHeight; - final oldMin = maxHeight - radius; - const newMax = 0.0; - final newMin = radius; - - radius = ((((oldValue - oldMin) * (newMax - newMin)) / - (oldMax - oldMin)) + - newMin) - .clamp(0, radius); - - calcHeight = screenTopPadding - - constraints.maxHeight + - widget.controller.pixels; - } - - return ClipRRect( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(radius), - topRight: Radius.circular(radius), - ), - child: Column( - children: [ + Widget build(BuildContext context) => ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + ), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: PointerDeviceKind.values.toSet(), + ), + child: LayoutBuilder( + builder: (context, constraints) => DraggableScrollableSheet( + initialChildSize: 0.3, + minChildSize: 32 / constraints.maxHeight, + snap: true, + expand: false, + snapSizes: const [0.3], + controller: widget.controller, + builder: (context, innerController) => DelayedControllerAttachmentBuilder( - listenable: innerController, - builder: (context, _) => SizedBox( - height: calcHeight.clamp(0, screenTopPadding), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - color: innerController.hasClients && - innerController.offset != 0 - ? Theme.of(context).colorScheme.surfaceContainer - : Theme.of(context).colorScheme.surface, - ), - ), - ), - Expanded( - child: ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: child, - ), - ), - ], - ), - ); - }, - child: Stack( - children: [ - // Future proofing if child is moved out: avoid dependency - // injection, as that may not be possible in future - BottomSheetScrollableProvider( - innerScrollController: innerController, - outerScrollController: widget.controller, - child: SizedBox( - width: double.infinity, - child: switch (widget.selectedTab) { - 0 => const HomeViewBottomSheet(), - 1 => context.select( - (p) => p.isDownloadSetupPanelVisible, - ) - ? const DownloadConfigurationViewBottomSheet() - : const RegionSelectionViewBottomSheet(), - _ => Placeholder(key: ValueKey(widget.selectedTab)), - }, - ), - ), - IgnorePointer( - child: DelayedControllerAttachmentBuilder( - listenable: widget.controller, - builder: (context, _) { - if (!widget.controller.isAttached) { - return const SizedBox.shrink(); - } + listenable: widget.controller, + builder: (context, child) { + final screenTopPadding = + MediaQueryData.fromView(View.of(context)).padding.top; - final calcHeight = SecondaryViewBottomSheet.topPadding - - (screenTopPadding - - constraints.maxHeight + - widget.controller.pixels); + final double paddingPusherHeight = + widget.controller.isAttached + ? (screenTopPadding - + constraints.maxHeight + + widget.controller.pixels) + .clamp(0, screenTopPadding) + : 0; - return SizedBox( - height: calcHeight.clamp( - 0, - SecondaryViewBottomSheet.topPadding, - ), - width: constraints.maxWidth, - child: Semantics( - label: MaterialLocalizations.of(context) - .modalBarrierDismissLabel, - container: true, - child: Center( - child: Container( - height: 4, - width: 32, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(2), - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.4), - ), + return Column( + children: [ + // Widget which pushes the contents out of the way of the + // system insets/padding + DelayedControllerAttachmentBuilder( + listenable: innerController, + builder: (context, _) => SizedBox( + height: paddingPusherHeight, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + color: innerController.hasClients && + innerController.offset != 0 + ? Theme.of(context).colorScheme.surfaceContainer + : Theme.of(context).colorScheme.surface, ), ), ), - ); - }, + Expanded( + child: ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: child, + ), + ), + ], + ); + }, + child: BottomSheetScrollableProvider( + innerScrollController: innerController, + outerScrollController: widget.controller, + child: SizedBox( + width: double.infinity, + child: switch (widget.selectedTab) { + 0 => const HomeViewBottomSheet(), + 1 => context.select( + (p) => p.isDownloadSetupPanelVisible, + ) + ? const DownloadConfigurationViewBottomSheet() + : const RegionSelectionViewBottomSheet(), + _ => Placeholder(key: ValueKey(widget.selectedTab)), + }, + ), ), ), - ], + ), ), ), - ), - ); - } + ); } diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart index bb8f0f48..1c1ee0e5 100644 --- a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart @@ -32,5 +32,6 @@ class BottomSheetScrollableProvider extends InheritedWidget { @override bool updateShouldNotify(covariant BottomSheetScrollableProvider oldWidget) => - oldWidget.innerScrollController != innerScrollController; + oldWidget.innerScrollController != innerScrollController || + oldWidget.outerScrollController != outerScrollController; } diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart deleted file mode 100644 index 1e9fd590..00000000 --- a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/bottom_sheet_top_spacer.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../bottom_sheet.dart'; -import '../components/delayed_frame_attached_dependent_builder.dart'; -import '../components/scrollable_provider.dart'; - -class BottomSheetTopSpacer extends StatelessWidget { - const BottomSheetTopSpacer({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final screenTopPadding = - MediaQueryData.fromView(View.of(context)).padding.top; - final outerScrollController = - BottomSheetScrollableProvider.outerScrollControllerOf(context); - - return SliverToBoxAdapter( - child: DelayedControllerAttachmentBuilder( - listenable: outerScrollController, - builder: (context, _) { - if (!outerScrollController.isAttached) { - return const SizedBox.shrink(); - } - - final maxHeight = outerScrollController.sizeToPixels(1); - - final oldValue = outerScrollController.pixels; - final oldMax = maxHeight; - final oldMin = maxHeight - screenTopPadding; - - const maxTopPadding = 0.0; - const minTopPadding = SecondaryViewBottomSheet.topPadding - 8; - - final double topPaddingHeight = - ((((oldValue - oldMin) * (maxTopPadding - minTopPadding)) / - (oldMax - oldMin)) + - minTopPadding) - .clamp(0.0, SecondaryViewBottomSheet.topPadding - 8); - - return SizedBox(height: topPaddingHeight); - }, - ), - ); - } -} diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart index 8cc207a0..078f2621 100644 --- a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart @@ -22,48 +22,33 @@ class TabHeader extends StatelessWidget { return SliverPersistentHeader( pinned: true, - delegate: PersistentHeader( + delegate: _PersistentHeader( child: DelayedControllerAttachmentBuilder( listenable: outerScrollController, builder: (context, _) { if (!outerScrollController.isAttached || innerScrollController.positions.length != 1) { - return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: SizedBox( - width: double.infinity, - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, + return Column( + children: [ + const _Handle(), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ) + + const EdgeInsets.only(bottom: 8), + child: SizedBox( + width: double.infinity, + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + ), ), - ), + ], ); } - final maxHeight = outerScrollController.sizeToPixels(1); - - final oldValue = outerScrollController.pixels; - final oldMax = maxHeight; - final oldMin = maxHeight - screenTopPadding; - - const maxMinimizeIndentButtonWidth = 40; - const maxMinimizeIndentSpacer = 16; - const minMinimizeIndent = 0; - - final double minimizeIndentButtonWidth = ((((oldValue - oldMin) * - (maxMinimizeIndentButtonWidth - - minMinimizeIndent)) / - (oldMax - oldMin)) + - minMinimizeIndent) - .clamp(0.0, 40); - - final double minimizeIndentSpacer = ((((oldValue - oldMin) * - (maxMinimizeIndentSpacer - minMinimizeIndent)) / - (oldMax - oldMin)) + - minMinimizeIndent) - .clamp(0.0, 16); - return AnimatedBuilder( animation: innerScrollController, builder: (context, child) => AnimatedContainer( @@ -74,13 +59,41 @@ class TabHeader extends StatelessWidget { : Theme.of(context).colorScheme.surface, child: child, ), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row( - children: [ - SizedBox( - width: minimizeIndentButtonWidth, + child: Column( + children: [ + const _Handle(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16) + + const EdgeInsets.only(bottom: 8), + child: AnimatedBuilder( + animation: outerScrollController, + builder: (context, child) { + double calc(double end) { + final animationDstPx = outerScrollController + .sizeToPixels(1 / 4); // from top + final animationTriggerPx = + outerScrollController.sizeToPixels(1) - + animationDstPx - + screenTopPadding; + + return (((outerScrollController.pixels - + animationTriggerPx) * + end) / + animationDstPx) + .clamp(0, end); + } + + return Row( + children: [ + SizedBox(width: calc(40), child: child), + SizedBox(width: calc(16)), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ); + }, child: ClipRRect( child: IconButton( onPressed: () { @@ -99,13 +112,8 @@ class TabHeader extends StatelessWidget { ), ), ), - SizedBox(width: minimizeIndentSpacer), - Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - ], - ), + ), + ], ), ); }, @@ -115,8 +123,34 @@ class TabHeader extends StatelessWidget { } } -class PersistentHeader extends SliverPersistentHeaderDelegate { - const PersistentHeader({required this.child}); +class _Handle extends StatelessWidget { + const _Handle(); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Semantics( + label: MaterialLocalizations.of(context).modalBarrierDismissLabel, + container: true, + child: Center( + child: Container( + height: 4, + width: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.4), + ), + ), + ), + ), + ); +} + +class _PersistentHeader extends SliverPersistentHeaderDelegate { + const _PersistentHeader({required this.child}); final Widget child; @@ -129,10 +163,10 @@ class PersistentHeader extends SliverPersistentHeaderDelegate { Align(child: child); @override - double get maxExtent => 60; + double get maxExtent => 84; @override - double get minExtent => 60; + double get minExtent => 84; @override bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; diff --git a/example/lib/src/shared/components/url_selector.dart b/example/lib/src/shared/components/url_selector.dart index 8ce01f39..68e2a5ed 100644 --- a/example/lib/src/shared/components/url_selector.dart +++ b/example/lib/src/shared/components/url_selector.dart @@ -77,63 +77,59 @@ class _URLSelectorState extends State { } @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) => SizedBox( - width: constraints.maxWidth, - child: StreamBuilder>>( - initialData: const { - _defaultUrlTemplate: ['(default)'], - }, - 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 - ..value = TextEditingValue.empty - ..value = oldValue; + Widget build(BuildContext context) => + StreamBuilder>>( + initialData: const { + _defaultUrlTemplate: ['(default)'], + }, + 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 + ..value = TextEditingValue.empty + ..value = oldValue; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: DropdownMenu( - controller: _urlTextController, - width: constraints.maxWidth, - requestFocusOnTap: true, - leadingIcon: const Icon(Icons.link), - label: const Text('URL Template'), - inputDecorationTheme: const InputDecorationTheme( - filled: true, - helperMaxLines: 2, - ), - initialSelection: widget.initialValue, - // Bug in `DropdownMenu` means this cannot be `true` - // enableFilter: true, - dropdownMenuEntries: _constructMenuEntries(snapshot), - onSelected: _onSelected, - helperText: 'Use standard placeholders & include protocol' - '${widget.helperText != null ? '\n${widget.helperText}' : ''}', - focusNode: _dropdownMenuFocusNode, - ), + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DropdownMenu( + controller: _urlTextController, + expandedInsets: EdgeInsets.zero, // full width + requestFocusOnTap: true, + leadingIcon: const Icon(Icons.link), + label: const Text('URL Template'), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + helperMaxLines: 2, ), - Padding( - padding: const EdgeInsets.only(top: 6, left: 8), - child: ValueListenableBuilder( - valueListenable: _enableAddUrlButton, - builder: (context, enableAddUrlButton, _) => - IconButton.filledTonal( - onPressed: - enableAddUrlButton ? () => _onSelected(null) : null, - icon: const Icon(Icons.add_link), - ), - ), + initialSelection: widget.initialValue, + // Bug in `DropdownMenu` means this cannot be `true` + // enableFilter: true, + dropdownMenuEntries: _constructMenuEntries(snapshot), + onSelected: _onSelected, + helperText: 'Use standard placeholders & include protocol' + '${widget.helperText != null ? '\n${widget.helperText}' : ''}', + focusNode: _dropdownMenuFocusNode, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6, left: 8), + child: ValueListenableBuilder( + valueListenable: _enableAddUrlButton, + builder: (context, enableAddUrlButton, _) => + IconButton.filledTonal( + onPressed: + enableAddUrlButton ? () => _onSelected(null) : null, + icon: const Icon(Icons.add_link), ), - ], - ); - }, - ), - ), + ), + ), + ], + ); + }, ); void _onSelected(String? v) { @@ -247,8 +243,10 @@ class _URLSelectorState extends State { } void _urlTextControllerListener() { - _enableAddUrlButton.value = - !_enableButtonEvaluatorMap.containsKey(_urlTextController.text); + WidgetsBinding.instance.addPostFrameCallback((_) { + _enableAddUrlButton.value = + !_enableButtonEvaluatorMap.containsKey(_urlTextController.text); + }); } } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 55bde925..9ff932d2 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -5,29 +5,29 @@ publish_to: "none" version: 10.0.0 environment: - sdk: ">=3.3.0 <4.0.0" - flutter: ">=3.19.0" + sdk: ">=3.6.0 <4.0.0" + flutter: ">=3.27.0" dependencies: - async: ^2.11.0 + async: ^2.12.0 auto_size_text: ^3.0.0 collection: ^1.18.0 - file_picker: ^8.1.2 + file_picker: 8.1.4 # Compatible with 3.27! flutter: sdk: flutter - flutter_map: ^7.0.2 - flutter_map_animations: ^0.7.1 + flutter_map: + flutter_map_animations: ^0.8.0 flutter_map_tile_caching: google_fonts: ^6.2.1 - gpx: ^2.2.2 + gpx: ^2.3.0 http: ^1.2.2 intl: ^0.19.0 latlong2: ^0.9.1 - path: ^1.9.0 - path_provider: ^2.1.4 + path: ^1.9.1 + path_provider: ^2.1.5 provider: ^6.1.2 - share_plus: ^10.0.2 - shared_preferences: ^2.3.2 + share_plus: ^10.1.3 + shared_preferences: ^2.3.3 stream_transform: ^2.1.0 dependency_overrides: From 0af37da93b4860570ff6a628d909e32885d5738f Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 18 Dec 2024 19:11:05 +0100 Subject: [PATCH 73/97] Improved example application --- example/lib/main.dart | 6 + example/lib/src/screens/main/main.dart | 389 +++++++++++------- .../recovery_regions/recovery_regions.dart | 52 +++ .../src/screens/main/map_view/map_view.dart | 37 +- .../components/store_selector.dart | 3 + .../config_options/config_options.dart | 8 +- .../confirmation_panel.dart | 19 +- .../download_configuration_view_side.dart | 18 +- .../components/no_regions.dart | 32 ++ .../recoverable_regions_list.dart | 196 +++++++++ .../recovery/recovery_view_bottom_sheet.dart | 21 + .../contents/recovery/recovery_view_side.dart | 25 ++ .../components/shared/to_config_method.dart | 4 +- .../layouts/bottom_sheet/bottom_sheet.dart | 4 +- .../secondary_view/layouts/side/side.dart | 4 +- .../download_configuration_provider.dart | 17 +- .../src/shared/state/download_provider.dart | 5 + .../state/recoverable_regions_provider.dart | 15 + .../src/shared/state/selected_tab_state.dart | 3 + example/pubspec.yaml | 1 + lib/src/regions/downloadable_region.dart | 14 +- lib/src/regions/recovered_region.dart | 26 ++ lib/src/root/recovery.dart | 10 + lib/src/root/statistics.dart | 9 +- 24 files changed, 724 insertions(+), 194 deletions(-) create mode 100644 example/lib/src/screens/main/map_view/components/recovery_regions/recovery_regions.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/components/no_regions.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/recoverable_regions_list.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/recovery/recovery_view_bottom_sheet.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/recovery/recovery_view_side.dart create mode 100644 example/lib/src/shared/state/recoverable_regions_provider.dart create mode 100644 example/lib/src/shared/state/selected_tab_state.dart 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} /// From ac62961ebd6c28173981677e02338eeeff16f4cd Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 19 Dec 2024 13:36:26 +0100 Subject: [PATCH 74/97] Fixed bugs in circle region tile generator to respect `DownloadableRegion.start` & `.end` correctly Fixed bugs in bulk download statistics with `.start` and `.end` --- lib/flutter_map_tile_caching.dart | 1 - .../backend/internal_workers/thread_safe.dart | 4 +- .../backend/internal_thread_safe.dart | 2 +- lib/src/bulk_download/internal/manager.dart | 9 +- .../internal/tile_loops/generate.dart | 112 ++++++++++-------- 5 files changed, 71 insertions(+), 57 deletions(-) diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 84e2eb3a..82579859 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -33,7 +33,6 @@ import 'src/backend/export_internal.dart'; import 'src/bulk_download/internal/instance.dart'; import 'src/bulk_download/internal/rate_limited_stream.dart'; import 'src/bulk_download/internal/tile_loops/shared.dart'; -import 'src/misc/int_extremes.dart'; import 'src/providers/image_provider/browsing_errors.dart'; export 'src/backend/export_external.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart index 27355622..daccf201 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart @@ -151,7 +151,7 @@ class _ObjectBoxBackendThreadSafeImpl implements FMTCBackendInternalThreadSafe { required int id, required String storeName, required DownloadableRegion region, - required int endTile, + required int tilesCount, }) { expectInitialisedRoot; @@ -177,7 +177,7 @@ class _ObjectBoxBackendThreadSafeImpl implements FMTCBackendInternalThreadSafe { refId: id, storeName: storeName, region: region, - endTile: endTile, + endTile: region.end ?? (region.start - 1 + tilesCount), target: recursiveWriteRecoveryRegions(region.originalRegion), ), mode: PutMode.insert, diff --git a/lib/src/backend/interfaces/backend/internal_thread_safe.dart b/lib/src/backend/interfaces/backend/internal_thread_safe.dart index e38d173a..fa34acb9 100644 --- a/lib/src/backend/interfaces/backend/internal_thread_safe.dart +++ b/lib/src/backend/interfaces/backend/internal_thread_safe.dart @@ -75,7 +75,7 @@ abstract interface class FMTCBackendInternalThreadSafe { required int id, required String storeName, required DownloadableRegion region, - required int endTile, + required int tilesCount, }); /// Update the specified recovery entity with the new [RecoveredRegion.start] diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index 3c2bacae..52fe9b00 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -38,7 +38,7 @@ Future _downloadManager( }; // Count number of tiles - final maxTiles = input.region.when( + final tilesCount = input.region.when( rectangle: TileCounters.rectangleTiles, circle: TileCounters.circleTiles, line: TileCounters.lineTiles, @@ -145,7 +145,7 @@ Future _downloadManager( // Start progress tracking final initialDownloadProgress = DownloadProgress._initial( - maxTilesCount: maxTiles, + maxTilesCount: tilesCount, tilesPerSecondLimit: input.rateLimit, retryFailedRequestTiles: input.retryFailedRequestTiles, ); @@ -230,7 +230,7 @@ Future _downloadManager( id: recoveryId, storeName: input.storeName, region: input.region, - endTile: min(input.region.end ?? largestInt, maxTiles), + tilesCount: tilesCount, ); } @@ -239,7 +239,8 @@ Future _downloadManager( if (input.recoveryId case final recoveryId?) { input.backend.updateRecovery( id: recoveryId, - newStartTile: 1 + lastDownloadProgress.flushedTilesCount, + newStartTile: + input.region.start + lastDownloadProgress.flushedTilesCount, ); } } diff --git a/lib/src/bulk_download/internal/tile_loops/generate.dart b/lib/src/bulk_download/internal/tile_loops/generate.dart index 9253cb68..965e75c0 100644 --- a/lib/src/bulk_download/internal/tile_loops/generate.dart +++ b/lib/src/bulk_download/internal/tile_loops/generate.dart @@ -137,44 +137,48 @@ class TileGenerators { if (radius == 1) { tileCounter++; - if (tileCounter < start) continue; - if (tileCounter > end) { - if (!inMulti) Isolate.exit(); - return; - } + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } - await requestQueue.next; - sendPort.send((centerTile.x, centerTile.y, zoomLvl)); + await requestQueue.next; + sendPort.send((centerTile.x, centerTile.y, zoomLvl)); + } tileCounter++; - if (tileCounter < start) continue; - if (tileCounter > end) { - if (!inMulti) Isolate.exit(); - return; - } + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } - await requestQueue.next; - sendPort.send((centerTile.x, centerTile.y - 1, zoomLvl)); + await requestQueue.next; + sendPort.send((centerTile.x, centerTile.y - 1, zoomLvl)); + } tileCounter++; - if (tileCounter < start) continue; - if (tileCounter > end) { - if (!inMulti) Isolate.exit(); - return; - } + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } - await requestQueue.next; - sendPort.send((centerTile.x - 1, centerTile.y, zoomLvl)); + await requestQueue.next; + sendPort.send((centerTile.x - 1, centerTile.y, zoomLvl)); + } tileCounter++; - if (tileCounter < start) continue; - if (tileCounter > end) { - if (!inMulti) Isolate.exit(); - return; - } + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } - await requestQueue.next; - sendPort.send((centerTile.x - 1, centerTile.y - 1, zoomLvl)); + await requestQueue.next; + sendPort.send((centerTile.x - 1, centerTile.y - 1, zoomLvl)); + } continue; } @@ -183,24 +187,26 @@ class TileGenerators { final mdx = sqrt(radiusSquared - dy * dy).floor(); for (int dx = -mdx - 1; dx <= mdx; dx++) { tileCounter++; - if (tileCounter < start) continue; - if (tileCounter > end) { - if (!inMulti) Isolate.exit(); - return; - } + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } - await requestQueue.next; - sendPort.send((dx + centerTile.x, dy + centerTile.y, zoomLvl)); + await requestQueue.next; + sendPort.send((dx + centerTile.x, dy + centerTile.y, zoomLvl)); + } tileCounter++; - if (tileCounter < start) continue; - if (tileCounter > end) { - if (!inMulti) Isolate.exit(); - return; - } + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } - await requestQueue.next; - sendPort.send((dx + centerTile.x, -dy - 1 + centerTile.y, zoomLvl)); + await requestQueue.next; + sendPort.send((dx + centerTile.x, -dy - 1 + centerTile.y, zoomLvl)); + } } } } @@ -342,13 +348,6 @@ class TileGenerators { for (int x = straightRectangleNW.x; x <= straightRectangleSE.x; x++) { bool foundOverlappingTile = false; for (int y = straightRectangleNW.y; y <= straightRectangleSE.y; y++) { - tileCounter++; - if (tileCounter < start) continue; - if (tileCounter > end) { - if (!inMulti) Isolate.exit(); - return; - } - final tile = _Polygon( Point(x, y), Point(x + 1, y), @@ -368,6 +367,14 @@ class TileGenerators { )) { generatedTiles.add(tile.hashCode); foundOverlappingTile = true; + + tileCounter++; + if (tileCounter < start) continue; + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; sendPort.send((x, y, zoomLvl.toInt())); } else if (foundOverlappingTile) { @@ -455,6 +462,13 @@ class TileGenerators { final xsMax = xsRawMax - i; for (int x = xsMin; x <= xsMax; x++) { + tileCounter++; + if (tileCounter < start) continue; + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; sendPort.send((x, y, zoomLvl.toInt())); } From c6925be302a76cf604e3fb40ae26ba28271de36b Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 19 Dec 2024 17:16:13 +0100 Subject: [PATCH 75/97] Improved example application --- .../src/screens/main/layouts/horizontal.dart | 89 +++++ .../src/screens/main/layouts/vertical.dart | 115 ++++++ example/lib/src/screens/main/main.dart | 178 +-------- .../confirmation_panel.dart | 4 +- .../map_configurator/map_configurator.dart | 2 +- .../components/store_tiles/store_tile.dart | 355 ------------------ .../{store_tiles => tiles}/root_tile.dart | 0 .../browse_store_strategy_selector.dart | 4 +- .../checkbox.dart | 0 .../dropdown.dart | 0 .../store_empty_deletion_dialog.dart | 141 +++++++ .../tiles/store_tile/components/trailing.dart | 163 ++++++++ .../tiles/store_tile/store_tile.dart | 243 ++++++++++++ .../unspecified_tile.dart | 2 +- .../components/stores_list/stores_list.dart | 8 +- .../recoverable_regions_list.dart | 2 +- .../screens/store_editor/store_editor.dart | 2 +- .../src/shared/components/url_selector.dart | 8 +- .../src/shared/state/download_provider.dart | 31 +- 19 files changed, 800 insertions(+), 547 deletions(-) create mode 100644 example/lib/src/screens/main/layouts/horizontal.dart create mode 100644 example/lib/src/screens/main/layouts/vertical.dart delete mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart rename example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/{store_tiles => tiles}/root_tile.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/{store_tiles => tiles/store_tile/components}/browse_store_strategy_selector/browse_store_strategy_selector.dart (95%) rename example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/{store_tiles => tiles/store_tile/components}/browse_store_strategy_selector/checkbox.dart (100%) rename example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/{store_tiles => tiles/store_tile/components}/browse_store_strategy_selector/dropdown.dart (100%) create mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/store_empty_deletion_dialog.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart rename example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/{store_tiles => tiles}/unspecified_tile.dart (97%) diff --git a/example/lib/src/screens/main/layouts/horizontal.dart b/example/lib/src/screens/main/layouts/horizontal.dart new file mode 100644 index 00000000..337d43ae --- /dev/null +++ b/example/lib/src/screens/main/layouts/horizontal.dart @@ -0,0 +1,89 @@ +part of '../main.dart'; + +class _HorizontalLayout extends StatelessWidget { + const _HorizontalLayout({ + required DraggableScrollableController bottomSheetOuterController, + required this.mapMode, + required this.selectedTab, + }) : _bottomSheetOuterController = bottomSheetOuterController; + + final DraggableScrollableController _bottomSheetOuterController; + final MapViewMode mapMode; + final int selectedTab; + + @override + Widget build(BuildContext context) => 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.storeName != null, + 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, + ), + 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: Axis.horizontal, + ), + ), + ), + ], + ), + ), + ); +} diff --git a/example/lib/src/screens/main/layouts/vertical.dart b/example/lib/src/screens/main/layouts/vertical.dart new file mode 100644 index 00000000..d933dcd3 --- /dev/null +++ b/example/lib/src/screens/main/layouts/vertical.dart @@ -0,0 +1,115 @@ +part of '../main.dart'; + +class _VerticalLayout extends StatelessWidget { + const _VerticalLayout({ + required DraggableScrollableController bottomSheetOuterController, + required this.mapMode, + required this.selectedTab, + required this.constrainedHeight, + }) : _bottomSheetOuterController = bottomSheetOuterController; + + final DraggableScrollableController _bottomSheetOuterController; + final MapViewMode mapMode; + final int selectedTab; + final double constrainedHeight; + + @override + Widget build(BuildContext context) => Scaffold( + body: BottomSheetMapWrapper( + bottomSheetOuterController: _bottomSheetOuterController, + mode: mapMode, + layoutDirection: Axis.vertical, + ), + bottomSheet: SecondaryViewBottomSheet( + selectedTab: selectedTab, + controller: _bottomSheetOuterController, + ), + floatingActionButton: selectedTab == 1 && + context + .watch() + .constructedRegions + .isNotEmpty + ? DelayedControllerAttachmentBuilder( + listenable: _bottomSheetOuterController, + builder: (context, _) => AnimatedBuilder( + animation: _bottomSheetOuterController, + builder: (context, _) { + final pixels = _bottomSheetOuterController.isAttached + ? _bottomSheetOuterController.pixels + : 0; + return FloatingActionButton( + onPressed: () async { + await _bottomSheetOuterController.animateTo( + 2 / 3, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + if (!context.mounted) return; + prepareDownloadConfigView( + context, + shouldShowConfig: pixels > 33, + ); + }, + tooltip: + pixels <= 33 ? 'Show regions' : 'Configure download', + child: 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: Selector( + selector: (context, provider) => provider.storeName != null, + builder: (context, isDownloading, child) => + !isDownloading ? child! : Badge(child: child), + child: const Icon(Icons.download_outlined), + ), + selectedIcon: const 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 / constrainedHeight, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ), + ); + } else { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _bottomSheetOuterController.animateTo( + 0.3, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ), + ); + } + }, + ), + ); +} diff --git a/example/lib/src/screens/main/main.dart b/example/lib/src/screens/main/main.dart index e6b6961f..74ea02fd 100644 --- a/example/lib/src/screens/main/main.dart +++ b/example/lib/src/screens/main/main.dart @@ -16,6 +16,9 @@ import 'secondary_view/layouts/bottom_sheet/bottom_sheet.dart'; import 'secondary_view/layouts/bottom_sheet/components/delayed_frame_attached_dependent_builder.dart'; import 'secondary_view/layouts/side/side.dart'; +part 'layouts/horizontal.dart'; +part 'layouts/vertical.dart'; + class MainScreen extends StatefulWidget { const MainScreen({super.key}); @@ -88,175 +91,18 @@ class _MainScreenState extends State { 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, - 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, - ), - ); - } else { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _bottomSheetOuterController.animateTo( - 0.3, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ), - ); - } - }, - ), + return _VerticalLayout( + bottomSheetOuterController: _bottomSheetOuterController, + mapMode: mapMode, + selectedTab: selectedTab, + constrainedHeight: constraints.maxHeight, ); } - 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, - ), - 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, - ), - ), - ), - ], - ), - ), + return _HorizontalLayout( + bottomSheetOuterController: _bottomSheetOuterController, + mapMode: mapMode, + selectedTab: selectedTab, ); }, ); 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 b5b954ff..73c5bb84 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 @@ -249,12 +249,14 @@ class _ConfirmationPanelState extends State { rateLimit: downloadConfiguration.rateLimit, ); - downloadingProvider.assignDownload( + await downloadingProvider.assignDownload( storeName: downloadConfiguration.selectedStoreName!, downloadableRegion: downloadableRegion, downloadStreams: downloadStreams, ); + await Future.delayed(const Duration(milliseconds: 200)); + if (downloadConfiguration.fromRecovery case final recoveryId?) { unawaited(FMTCRoot.recovery.cancel(recoveryId)); downloadConfiguration.fromRecovery = null; diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/map_configurator.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/map_configurator.dart index 54112647..1213004f 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/map_configurator.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/map_configurator.dart @@ -25,7 +25,7 @@ class _MapConfiguratorState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - URLSelector( + UrlSelector( initialValue: context.select( (provider) => provider.urlTemplate, ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart deleted file mode 100644 index e67928c3..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/store_tile.dart +++ /dev/null @@ -1,355 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../../../../../shared/misc/exts/size_formatter.dart'; -import '../../../../../../../../../shared/misc/store_metadata_keys.dart'; -import '../../../../../../../../../shared/state/general_provider.dart'; -import '../../../../../../../../store_editor/store_editor.dart'; -import '../../state/export_selection_provider.dart'; -import 'browse_store_strategy_selector/browse_store_strategy_selector.dart'; - -class StoreTile extends StatefulWidget { - const StoreTile({ - super.key, - required this.store, - required this.stats, - required this.metadata, - required this.tileImage, - required this.useCompactLayout, - }); - - final FMTCStore store; - final Future<({int hits, int length, int misses, double size})> stats; - final Future> metadata; - final Future tileImage; - final bool useCompactLayout; - - @override - State createState() => _StoreTileState(); -} - -class _StoreTileState extends State { - bool _toolsVisible = false; - bool _toolsEmptyLoading = false; - bool _toolsDeleteLoading = false; - Timer? _toolsAutoHiderTimer; - - @override - void dispose() { - _toolsAutoHiderTimer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final storeName = widget.store.storeName; - - return RepaintBoundary( - child: Material( - color: Colors.transparent, - child: Consumer2( - builder: (context, provider, exportSelectionProvider, _) => - FutureBuilder( - future: widget.metadata, - builder: (context, metadataSnapshot) { - final matchesUrl = metadataSnapshot.data != null && - provider.urlTemplate == - metadataSnapshot.data![StoreMetadataKeys.urlTemplate.key]; - - final toolsChildren = [ - const SizedBox(width: 4), - IconButton( - onPressed: _exportStore, - icon: const Icon(Icons.send_and_archive), - visualDensity: - widget.useCompactLayout ? VisualDensity.compact : null, - ), - const SizedBox(width: 4), - IconButton( - onPressed: _editStore, - icon: const Icon(Icons.edit), - visualDensity: - widget.useCompactLayout ? VisualDensity.compact : null, - ), - const SizedBox(width: 4), - FutureBuilder( - future: widget.stats, - builder: (context, statsSnapshot) { - if (statsSnapshot.data?.length == 0) { - return IconButton( - onPressed: _deleteStore, - icon: const Icon( - Icons.delete_forever, - color: Colors.red, - ), - visualDensity: widget.useCompactLayout - ? VisualDensity.compact - : null, - ); - } - - if (_toolsEmptyLoading) { - return const IconButton( - onPressed: null, - icon: SizedBox.square( - dimension: 18, - child: Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 3, - ), - ), - ), - ); - } - - return IconButton( - onPressed: _emptyStore, - icon: const Icon(Icons.delete), - visualDensity: widget.useCompactLayout - ? VisualDensity.compact - : null, - ); - }, - ), - const SizedBox(width: 4), - ]; - - final exportModeChildren = [ - const Icon(Icons.note_add), - const SizedBox(width: 12), - Checkbox.adaptive( - value: exportSelectionProvider.selectedStores - .contains(storeName), - onChanged: (v) { - if (v!) { - context - .read() - .addSelectedStore(storeName); - } else if (!v) { - context - .read() - .removeSelectedStore(storeName); - } - }, - ), - ]; - - return InkWell( - onSecondaryTap: _showTools, - child: ListTile( - title: Text( - storeName, - maxLines: 1, - overflow: TextOverflow.fade, - softWrap: false, - ), - subtitle: FutureBuilder( - future: widget.stats, - builder: (context, statsSnapshot) { - if (statsSnapshot.data case final stats?) { - return Text( - '${(stats.size * 1024).asReadableSize} | ' - '${stats.length} tiles', - ); - } - return const Text('Loading stats...'); - }, - ), - leading: AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: RepaintBoundary( - child: FutureBuilder( - future: widget.tileImage, - builder: (context, snapshot) { - if (snapshot.data case final data?) return data; - return const Icon(Icons.filter_none); - }, - ), - ), - ), - ), - trailing: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - ), - child: IntrinsicWidth( - child: IntrinsicHeight( - child: Stack( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.all(4), - child: BrowseStoreStrategySelector( - storeName: widget.store.storeName, - enabled: matchesUrl, - useCompactLayout: widget.useCompactLayout, - ), - ), - ), - AnimatedOpacity( - opacity: matchesUrl ? 0 : 1, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: matchesUrl, - child: SizedBox.expand( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .error - .withValues(alpha: 0.75), - borderRadius: BorderRadius.circular(12), - ), - child: const Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - Icon( - Icons.link_off, - color: Colors.white, - ), - Text( - 'URL mismatch', - style: TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ], - ), - ), - ), - ), - ), - AnimatedOpacity( - opacity: _toolsVisible ? 1 : 0, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: !_toolsVisible, - child: SizedBox.expand( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceDim, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - ), - child: _toolsDeleteLoading - ? const Center( - child: SizedBox.square( - dimension: 25, - child: Center( - child: - CircularProgressIndicator - .adaptive(), - ), - ), - ) - : Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: toolsChildren, - ), - ), - ), - ), - ), - ), - AnimatedOpacity( - opacity: exportSelectionProvider - .selectedStores.isNotEmpty - ? 1 - : 0, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: exportSelectionProvider - .selectedStores.isEmpty, - child: SizedBox.expand( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceDim, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: exportModeChildren, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - onLongPress: _showTools, - onTap: _hideTools, - ), - ); - }, - ), - ), - ), - ); - } - - Future _exportStore() async { - context - .read() - .addSelectedStore(widget.store.storeName); - await _hideTools(); - } - - Future _editStore() async { - await Navigator.of(context).pushNamed( - StoreEditorPopup.route, - arguments: widget.store.storeName, - ); - await _hideTools(); - } - - Future _emptyStore() async { - setState(() => _toolsEmptyLoading = true); - await widget.store.manage.reset(); - setState(() => _toolsEmptyLoading = false); - } - - Future _deleteStore() async { - _toolsAutoHiderTimer?.cancel(); - setState(() => _toolsDeleteLoading = true); - await widget.store.manage.delete(); - } - - Future _hideTools() async { - setState(() => _toolsVisible = false); - _toolsAutoHiderTimer?.cancel(); - return Future.delayed(const Duration(milliseconds: 150)); - } - - void _showTools() { - setState(() => _toolsVisible = true); - _toolsAutoHiderTimer = Timer(const Duration(seconds: 5), _hideTools); - } -} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/root_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/root_tile.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/root_tile.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/root_tile.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart similarity index 95% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart index 7bf81996..a8595ac3 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/browse_store_strategy_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; -import '../../../../../../../../../../shared/state/general_provider.dart'; +import '../../../../../../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; +import '../../../../../../../../../../../../shared/state/general_provider.dart'; part 'checkbox.dart'; part 'dropdown.dart'; diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/checkbox.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/checkbox.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/dropdown.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/browse_store_strategy_selector/dropdown.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/store_empty_deletion_dialog.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/store_empty_deletion_dialog.dart new file mode 100644 index 00000000..58137247 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/store_empty_deletion_dialog.dart @@ -0,0 +1,141 @@ +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'; + +class StoreEmptyDeletionDialog extends StatefulWidget { + const StoreEmptyDeletionDialog({ + super.key, + required this.storeName, + }); + + final String storeName; + + @override + State createState() => + _StoreEmptyDeletionDialogState(); +} + +class _StoreEmptyDeletionDialogState extends State { + late final _recoveryRegions = FMTCRoot.recovery.recoverableRegions.then( + (regions) => regions.failedOnly + .where((region) => region.storeName == widget.storeName) + .map((region) => region.id), + ); + late final _tilesCount = FMTCStore(widget.storeName).stats.length; + late final _combinedFutures = (_recoveryRegions, _tilesCount).wait; + + late final _isDownloading = + context.read().storeName == widget.storeName; + + @override + Widget build(BuildContext context) => AlertDialog.adaptive( + icon: const Icon(Icons.delete_forever), + title: Text( + 'Empty/delete ${widget.storeName}?', + textAlign: TextAlign.center, + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FutureBuilder( + future: _combinedFutures, + builder: (context, snapshot) { + if ((snapshot.data?.$1.length ?? 0) == 0) { + return const SizedBox.shrink(); + } + + return Text( + 'Deleting this store will also delete ' + '${snapshot.requireData.$1.length} associated recoverable ' + 'region(s).', + textAlign: TextAlign.center, + ); + }, + ), + const Text( + 'Emptying or deleting a store cannot be undone.', + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + SizedBox( + height: 40, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + SizedBox( + height: 40, + child: FutureBuilder( + future: _combinedFutures, + builder: (context, snapshot) { + if (snapshot.data == null) return const SizedBox.shrink(); + if (snapshot.requireData.$2 == 0) { + return const FilledButton.tonal( + onPressed: null, + child: Text('Empty'), + ); + } + return FilledButton.tonal( + onPressed: () => Navigator.of(context).pop( + ( + isDeleting: false, + future: FMTCStore(widget.storeName).manage.reset(), + ), + ), + child: Text('Empty ${snapshot.requireData.$2} tiles'), + ); + }, + ), + ), + SizedBox( + height: 40, + child: FutureBuilder( + future: _combinedFutures, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const UnconstrainedBox( + child: SizedBox.square( + dimension: 30, + child: CircularProgressIndicator.adaptive(), + ), + ); + } + + final button = FilledButton( + onPressed: _isDownloading + ? null + : () => Navigator.of(context).pop( + ( + isDeleting: true, + future: Future.wait( + [ + FMTCStore(widget.storeName).manage.delete(), + ...snapshot.requireData.$1.map( + (id) => FMTCRoot.recovery.cancel(id), + ), + ], + ) + ), + ), + child: const Text('Delete'), + ); + + if (!_isDownloading) return button; + + return Tooltip( + message: + 'Cannot delete store whilst a\ndownload is in progress', + textAlign: TextAlign.center, + child: button, + ); + }, + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart new file mode 100644 index 00000000..5268c730 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart @@ -0,0 +1,163 @@ +part of '../store_tile.dart'; + +class _Trailing extends StatelessWidget { + const _Trailing({ + required this.storeName, + required this.matchesUrl, + required this.isToolsVisible, + required this.isDeleting, + required this.useCompactLayout, + required this.toolsChildren, + }); + + final String storeName; + final bool matchesUrl; + final bool isToolsVisible; + final bool isDeleting; + final bool useCompactLayout; + final List toolsChildren; + + @override + Widget build(BuildContext context) { + final urlMismatch = AnimatedOpacity( + opacity: matchesUrl ? 0 : 1, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: matchesUrl, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.error.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Icon( + Icons.link_off, + color: Colors.white, + ), + Text( + 'URL mismatch', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ), + ); + + final tools = AnimatedOpacity( + opacity: isToolsVisible ? 1 : 0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: !isToolsVisible, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceDim, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + ), + child: isDeleting + ? const Center( + child: SizedBox.square( + dimension: 25, + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: toolsChildren, + ), + ), + ), + ), + ), + ); + + final exportCheckbox = Selector>( + selector: (context, provider) => provider.selectedStores, + builder: (context, selectedStores, _) => AnimatedOpacity( + opacity: selectedStores.isNotEmpty ? 1 : 0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: selectedStores.isEmpty, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceDim, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.note_add), + const SizedBox(width: 12), + Checkbox.adaptive( + value: selectedStores.contains(storeName), + onChanged: (v) { + if (v!) { + context + .read() + .addSelectedStore(storeName); + } else if (!v) { + context + .read() + .removeSelectedStore(storeName); + } + }, + ), + ], + ), + ), + ), + ), + ), + ), + ); + + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: IntrinsicWidth( + child: IntrinsicHeight( + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(4), + child: BrowseStoreStrategySelector( + storeName: storeName, + enabled: matchesUrl, + useCompactLayout: useCompactLayout, + ), + ), + ), + urlMismatch, + tools, + exportCheckbox, + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart new file mode 100644 index 00000000..b146e198 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../../../../shared/misc/exts/size_formatter.dart'; +import '../../../../../../../../../../shared/misc/store_metadata_keys.dart'; +import '../../../../../../../../../../shared/state/general_provider.dart'; +import '../../../../../../../../../store_editor/store_editor.dart'; +import '../../../state/export_selection_provider.dart'; +import 'components/browse_store_strategy_selector/browse_store_strategy_selector.dart'; +import 'components/store_empty_deletion_dialog.dart'; + +part 'components/trailing.dart'; + +class StoreTile extends StatefulWidget { + const StoreTile({ + super.key, + required this.storeName, + required this.stats, + required this.metadata, + required this.tileImage, + required this.useCompactLayout, + }); + + final String storeName; + final Future<({int hits, int length, int misses, double size})> stats; + final Future> metadata; + final Future tileImage; + final bool useCompactLayout; + + @override + State createState() => _StoreTileState(); +} + +class _StoreTileState extends State { + bool _isToolsVisible = false; + bool _isEmptying = false; + bool _isDeleting = false; + Timer? _toolsAutoHiderTimer; + + @override + void dispose() { + _toolsAutoHiderTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => RepaintBoundary( + child: Material( + color: Colors.transparent, + child: FutureBuilder( + future: widget.metadata, + builder: (context, metadataSnapshot) { + final matchesUrl = metadataSnapshot.data != null && + context.select( + (provider) => provider.urlTemplate, + ) == + metadataSnapshot.data![StoreMetadataKeys.urlTemplate.key]; + + final toolsChildren = [ + const SizedBox(width: 4), + IconButton( + onPressed: _exportStore, + icon: const Icon(Icons.send_and_archive), + visualDensity: + widget.useCompactLayout ? VisualDensity.compact : null, + ), + const SizedBox(width: 4), + IconButton( + onPressed: _editStore, + icon: const Icon(Icons.edit), + visualDensity: + widget.useCompactLayout ? VisualDensity.compact : null, + ), + const SizedBox(width: 4), + if (_isEmptying) + const IconButton( + onPressed: null, + icon: SizedBox.square( + dimension: 18, + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 3, + ), + ), + ), + ) + else + IconButton( + onPressed: _emptyDeleteStore, + icon: const Icon(Icons.delete), + visualDensity: + widget.useCompactLayout ? VisualDensity.compact : null, + ), + /*FutureBuilder( + future: widget.stats, + builder: (context, statsSnapshot) { + if (statsSnapshot.data?.length == 0) { + return IconButton( + onPressed: _deleteStore, + icon: const Icon( + Icons.delete_forever, + color: Colors.red, + ), + visualDensity: widget.useCompactLayout + ? VisualDensity.compact + : null, + ); + } + + if (_toolsEmptyLoading) { + return const IconButton( + onPressed: null, + icon: SizedBox.square( + dimension: 18, + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 3, + ), + ), + ), + ); + } + + return IconButton( + onPressed: _emptyStore, + icon: const Icon(Icons.delete), + visualDensity: widget.useCompactLayout + ? VisualDensity.compact + : null, + ); + }, + ),*/ + const SizedBox(width: 4), + ]; + + return InkWell( + onSecondaryTap: _showTools, + child: ListTile( + title: Text( + widget.storeName, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + ), + subtitle: FutureBuilder( + future: widget.stats, + builder: (context, statsSnapshot) { + if (statsSnapshot.data case final stats?) { + return Text( + '${(stats.size * 1024).asReadableSize} | ' + '${stats.length} tiles', + ); + } + return const Text('Loading stats...'); + }, + ), + leading: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: RepaintBoundary( + child: FutureBuilder( + future: widget.tileImage, + builder: (context, snapshot) { + if (snapshot.data case final data?) return data; + return const Icon(Icons.filter_none); + }, + ), + ), + ), + ), + trailing: _Trailing( + storeName: widget.storeName, + matchesUrl: matchesUrl, + isToolsVisible: _isToolsVisible, + isDeleting: _isDeleting, + useCompactLayout: widget.useCompactLayout, + toolsChildren: toolsChildren, + ), + onLongPress: _showTools, + onTap: _hideTools, + ), + ); + }, + ), + ), + ); + + Future _exportStore() async { + context.read().addSelectedStore(widget.storeName); + await _hideTools(); + } + + Future _editStore() async { + await Navigator.of(context).pushNamed( + StoreEditorPopup.route, + arguments: widget.storeName, + ); + await _hideTools(); + } + + Future _emptyDeleteStore() async { + _toolsAutoHiderTimer?.cancel(); + + final result = await showDialog<({Future future, bool isDeleting})>( + context: context, + builder: (context) => + StoreEmptyDeletionDialog(storeName: widget.storeName), + ); + + if (result == null) { + setState(() => _isToolsVisible = false); + return; + } + + if (result.isDeleting) { + setState(() => _isDeleting = true); + await result.future; + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Deleted ${widget.storeName}')), + ); + return; + } + + setState(() => _isEmptying = true); + await result.future; + setState(() => _isEmptying = false); + } + + Future _hideTools() async { + setState(() => _isToolsVisible = false); + _toolsAutoHiderTimer?.cancel(); + return Future.delayed(const Duration(milliseconds: 150)); + } + + void _showTools() { + setState(() => _isToolsVisible = true); + _toolsAutoHiderTimer = Timer(const Duration(seconds: 5), _hideTools); + } +} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/unspecified_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart similarity index 97% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/unspecified_tile.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart index 9516194b..7e9e8513 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/store_tiles/unspecified_tile.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart @@ -3,7 +3,7 @@ import 'package:provider/provider.dart'; import '../../../../../../../../../shared/misc/internal_store_read_write_behaviour.dart'; import '../../../../../../../../../shared/state/general_provider.dart'; -import 'browse_store_strategy_selector/browse_store_strategy_selector.dart'; +import 'store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart'; class UnspecifiedTile extends StatefulWidget { const UnspecifiedTile({ diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart index c8ed8807..b9064de3 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart @@ -7,9 +7,9 @@ import 'components/column_headers_and_inheritable_settings.dart'; import 'components/export_stores/button.dart'; import 'components/new_store_button.dart'; import 'components/no_stores.dart'; -import 'components/store_tiles/root_tile.dart'; -import 'components/store_tiles/store_tile.dart'; -import 'components/store_tiles/unspecified_tile.dart'; +import 'components/tiles/root_tile.dart'; +import 'components/tiles/store_tile/store_tile.dart'; +import 'components/tiles/unspecified_tile.dart'; import 'state/export_selection_provider.dart'; class StoresList extends StatefulWidget { @@ -110,7 +110,7 @@ class _StoresListState extends State { return StoreTile( key: ValueKey(store), - store: store, + storeName: store.storeName, stats: stats, metadata: metadata, tileImage: tileImage, 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 index 1a5208d0..b6ee3b97 100644 --- 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 @@ -141,7 +141,7 @@ class _RecoverableRegionsListState extends State { height: double.infinity, child: Selector( selector: (context, provider) => - provider.isDownloading, + provider.storeName != null, builder: (context, isDownloading, _) => Selector( selector: (context, provider) => diff --git a/example/lib/src/screens/store_editor/store_editor.dart b/example/lib/src/screens/store_editor/store_editor.dart index 897208ee..fa20edba 100644 --- a/example/lib/src/screens/store_editor/store_editor.dart +++ b/example/lib/src/screens/store_editor/store_editor.dart @@ -99,7 +99,7 @@ class _StoreEditorPopupState extends State { return const CircularProgressIndicator.adaptive(); } - return URLSelector( + return UrlSelector( onSelected: (input) => newUrlTemplate = input, initialValue: snapshot .data?[StoreMetadataKeys.urlTemplate.key] ?? diff --git a/example/lib/src/shared/components/url_selector.dart b/example/lib/src/shared/components/url_selector.dart index 68e2a5ed..3e9aeea9 100644 --- a/example/lib/src/shared/components/url_selector.dart +++ b/example/lib/src/shared/components/url_selector.dart @@ -9,8 +9,8 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import '../misc/shared_preferences.dart'; import '../misc/store_metadata_keys.dart'; -class URLSelector extends StatefulWidget { - const URLSelector({ +class UrlSelector extends StatefulWidget { + const UrlSelector({ super.key, required this.initialValue, this.onSelected, @@ -26,10 +26,10 @@ class URLSelector extends StatefulWidget { final void Function()? onUnfocus; @override - State createState() => _URLSelectorState(); + State createState() => _UrlSelectorState(); } -class _URLSelectorState extends State { +class _UrlSelectorState extends State { static const _defaultUrlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; diff --git a/example/lib/src/shared/state/download_provider.dart b/example/lib/src/shared/state/download_provider.dart index 60a09c54..b23191c8 100644 --- a/example/lib/src/shared/state/download_provider.dart +++ b/example/lib/src/shared/state/download_provider.dart @@ -13,9 +13,6 @@ 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); @@ -31,9 +28,10 @@ class DownloadingProvider extends ChangeNotifier { Stream get rawTileEventStream => _rawTileEventsStream ?? (throw _notReadyError); - late String _storeName; + String? _storeName; + String? get storeName => _storeName; - void assignDownload({ + Future assignDownload({ required String storeName, required DownloadableRegion downloadableRegion, required ({ @@ -41,16 +39,20 @@ class DownloadingProvider extends ChangeNotifier { Stream tileEvents }) downloadStreams, }) { + final focused = Completer(); + _storeName = storeName; _downloadableRegion = downloadableRegion; - _isDownloading = true; _rawTileEventsStream = downloadStreams.tileEvents.asBroadcastStream(); downloadStreams.downloadProgress.listen( (evt) { // Focus on initial event - if (evt.attemptedTilesCount == 0) _isFocused = true; + if (evt.attemptedTilesCount == 0) { + _isFocused = true; + focused.complete(); + } // Update stored value _latestDownloadProgress = evt; @@ -67,26 +69,33 @@ class DownloadingProvider extends ChangeNotifier { _latestTileEvent = evt; notifyListeners(); }); + + return focused.future; } Future pause() async { - await FMTCStore(_storeName).download.pause(); + assert(_storeName != null, 'Download not in progress'); + await FMTCStore(_storeName!).download.pause(); _isPaused = true; notifyListeners(); } void resume() { - FMTCStore(_storeName).download.resume(); + assert(_storeName != null, 'Download not in progress'); + FMTCStore(_storeName!).download.resume(); _isPaused = false; notifyListeners(); } - Future cancel() => FMTCStore(_storeName).download.cancel(); + Future cancel() { + assert(_storeName != null, 'Download not in progress'); + return FMTCStore(_storeName!).download.cancel(); + } void reset() { _isFocused = false; _isComplete = false; - _isDownloading = false; + _storeName = null; notifyListeners(); } From 82ff2daadeee94b4e63e7d68d3dfab9a783bb979 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 20 Dec 2024 15:26:25 +0100 Subject: [PATCH 76/97] Minor bug fixes --- example/lib/main.dart | 4 +++- .../components/region_selection/region_shape.dart | 9 ++++++++- .../lib/src/screens/main/map_view/map_view.dart | 5 +++-- .../shared/state/region_selection_provider.dart | 6 +++--- lib/src/store/download.dart | 14 ++++++++++---- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 2f326f96..a758d012 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -104,13 +104,15 @@ class _AppContainer extends StatelessWidget { ), ChangeNotifierProvider( create: (_) => DownloadConfigurationProvider(), + lazy: true, ), ChangeNotifierProvider( create: (_) => DownloadingProvider(), - // cannot be lazy as must persist when user disposed + lazy: true, ), ChangeNotifierProvider( create: (_) => RecoverableRegionsProvider(), + lazy: true, ), ], child: MaterialApp( diff --git a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart index a5b93e3c..0e8f475c 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart @@ -4,6 +4,7 @@ import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; +import '../../../../../shared/state/general_provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; class RegionShape extends StatelessWidget { @@ -13,7 +14,13 @@ class RegionShape extends StatelessWidget { Widget build(BuildContext context) => Consumer( builder: (context, provider, _) { final ccc = provider.currentConstructingCoordinates; - final cnpp = provider.currentNewPointPos; + final cnpp = provider.currentNewPointPos ?? + context + .watch() + .animatedMapController + .mapController + .camera + .center; late final renderConstructingRegion = provider.currentRegionType == RegionType.line 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 f89e81ac..6a77633c 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -108,7 +108,8 @@ class _MapViewState extends State with TickerProviderStateMixin { final provider = context.read(); - final newPoint = provider.currentNewPointPos; + final newPoint = provider.currentNewPointPos ?? + _mapController.mapController.camera.center; switch (provider.currentRegionType) { case RegionType.rectangle: @@ -196,7 +197,7 @@ class _MapViewState extends State with TickerProviderStateMixin { .latLngToScreenPoint(coords.first) .toOffset(); final centerPos = _mapController.mapController.camera - .latLngToScreenPoint(provider.currentNewPointPos) + .latLngToScreenPoint(provider.currentNewPointPos!) .toOffset(); provider.customPolygonSnap = coords.first != coords.last && sqrt( diff --git a/example/lib/src/shared/state/region_selection_provider.dart b/example/lib/src/shared/state/region_selection_provider.dart index d16248b2..dfcb13d6 100644 --- a/example/lib/src/shared/state/region_selection_provider.dart +++ b/example/lib/src/shared/state/region_selection_provider.dart @@ -29,9 +29,9 @@ class RegionSelectionProvider extends ChangeNotifier { notifyListeners(); } - LatLng _currentNewPointPos = const LatLng(51.509364, -0.128928); - LatLng get currentNewPointPos => _currentNewPointPos; - set currentNewPointPos(LatLng newPos) { + LatLng? _currentNewPointPos; + LatLng? get currentNewPointPos => _currentNewPointPos; + set currentNewPointPos(LatLng? newPos) { _currentNewPointPos = newPos; notifyListeners(); } diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index bb0ca2eb..87e01564 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -375,8 +375,11 @@ class StoreDownload { /// /// Does nothing (returns immediately) if there is no ongoing download or the /// download is already paused. - Future pause({Object instanceId = 0}) async => - await DownloadInstance.get(instanceId)?.requestPause?.call(); + Future pause({Object instanceId = 0}) async { + final instance = DownloadInstance.get(instanceId); + if (instance == null || instance.isPaused) return; + await instance.requestPause!.call(); + } /// Resume (after a [pause]) the ongoing foreground download /// @@ -384,8 +387,11 @@ class StoreDownload { /// /// Does nothing if there is no ongoing download or the download is already /// running. - void resume({Object instanceId = 0}) => - DownloadInstance.get(instanceId)?.requestResume?.call(); + void resume({Object instanceId = 0}) { + final instance = DownloadInstance.get(instanceId); + if (instance == null || !instance.isPaused) return; + instance.requestResume!.call(); + } /// Whether the ongoing foreground download is currently paused after a call /// to [pause] (and prior to [resume]) From 341c947c95621354e79f5f80ee57f4fa8fca8154 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 20 Dec 2024 15:37:06 +0100 Subject: [PATCH 77/97] Fixed minor layout bug in example app --- .../components/no_regions.dart | 6 +- .../components/tile_resume_button.dart | 35 +++++ .../recoverable_regions_list.dart | 141 ++++++++---------- .../contents/recovery/recovery_view_side.dart | 9 +- 4 files changed, 102 insertions(+), 89 deletions(-) create mode 100644 example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/components/tile_resume_button.dart 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 index f395f1d3..6fb103d7 100644 --- 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 @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; +part of '../recoverable_regions_list.dart'; -class NoRegions extends StatelessWidget { - const NoRegions({super.key}); +class _NoRegions extends StatelessWidget { + const _NoRegions(); @override Widget build(BuildContext context) => SliverFillRemaining( diff --git a/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/components/tile_resume_button.dart b/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/components/tile_resume_button.dart new file mode 100644 index 00000000..343e11c4 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/components/tile_resume_button.dart @@ -0,0 +1,35 @@ +part of '../recoverable_regions_list.dart'; + +class _ResumeButton extends StatelessWidget { + const _ResumeButton({ + required this.resumeDownload, + }); + + final void Function() resumeDownload; + + @override + Widget build(BuildContext context) => Selector( + selector: (context, provider) => provider.storeName != null, + 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, + icon: const Icon(Icons.download), + label: const Text('Resume'), + ); + + if (!cannotResume) return button; + + return Tooltip( + message: 'Cannot start another download', + child: button, + ); + }, + ), + ); +} 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 index b6ee3b97..bf8df1d0 100644 --- 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 @@ -15,7 +15,9 @@ 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'; + +part 'components/no_regions.dart'; +part 'components/tile_resume_button.dart'; class RecoverableRegionsList extends StatefulWidget { const RecoverableRegionsList({super.key}); @@ -92,86 +94,69 @@ class _RecoverableRegionsListState extends State { UnmodifiableMapView, 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.storeName != null, - 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, - ); - }, + if (failedRegions.isEmpty) return const _NoRegions(); + + return SliverPadding( + padding: const EdgeInsets.only(top: 16, bottom: 16), + sliver: 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: _ResumeButton( + resumeDownload: () => + _resumeDownload(failedRegion), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), - ), - ); - }, + ); + }, + ), ); }, ); 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 index 8df3c197..a404a623 100644 --- 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 @@ -13,13 +13,6 @@ class RecoveryViewSide extends StatelessWidget { ), width: double.infinity, height: double.infinity, - child: const CustomScrollView( - slivers: [ - SliverPadding( - padding: EdgeInsets.only(top: 16, bottom: 16), - sliver: RecoverableRegionsList(), - ), - ], - ), + child: const CustomScrollView(slivers: [RecoverableRegionsList()]), ); } From c5cdb803b5ef40e84dae9608c07e86e5e5d0a371 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 20 Dec 2024 22:36:19 +0100 Subject: [PATCH 78/97] Improved example app --- example/lib/src/screens/import/import.dart | 2 +- .../src/screens/main/layouts/horizontal.dart | 207 ++++++++++++------ example/lib/src/screens/main/main.dart | 2 +- .../fmtc_not_in_use_indicator.dart | 53 +++-- .../secondary_view/layouts/side/side.dart | 105 ++++++--- 5 files changed, 251 insertions(+), 118 deletions(-) diff --git a/example/lib/src/screens/import/import.dart b/example/lib/src/screens/import/import.dart index 0df6fed4..6006c95c 100644 --- a/example/lib/src/screens/import/import.dart +++ b/example/lib/src/screens/import/import.dart @@ -84,7 +84,7 @@ class _ImportPopupState extends State { elevation: 1, ), body: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: 250), switchInCurve: Curves.easeInOut, switchOutCurve: Curves.easeInOut, transitionBuilder: (child, animation) => SlideTransition( diff --git a/example/lib/src/screens/main/layouts/horizontal.dart b/example/lib/src/screens/main/layouts/horizontal.dart index 337d43ae..b0bdda8b 100644 --- a/example/lib/src/screens/main/layouts/horizontal.dart +++ b/example/lib/src/screens/main/layouts/horizontal.dart @@ -1,6 +1,6 @@ part of '../main.dart'; -class _HorizontalLayout extends StatelessWidget { +class _HorizontalLayout extends StatefulWidget { const _HorizontalLayout({ required DraggableScrollableController bottomSheetOuterController, required this.mapMode, @@ -11,79 +11,160 @@ class _HorizontalLayout extends StatelessWidget { final MapViewMode mapMode; final int selectedTab; + @override + State<_HorizontalLayout> createState() => _HorizontalLayoutState(); +} + +class _HorizontalLayoutState extends State<_HorizontalLayout> { + bool _isSecondaryViewForceExpanded = true; + bool _isSecondaryViewUserExpanded = false; + BoxConstraints? _previousConstraints; + @override Widget build(BuildContext context) => 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.storeName != null, - builder: (context, isDownloading, child) => - !isDownloading ? child! : Badge(child: child), - child: const Icon(Icons.download_outlined), + builder: (context, constraints) { + if (constraints.maxWidth <= 1200 && + (_previousConstraints?.maxWidth ?? double.infinity) > 1200) { + _isSecondaryViewForceExpanded = false; + _isSecondaryViewUserExpanded = true; + } + if (constraints.maxWidth <= 1000 && + (_previousConstraints?.maxWidth ?? double.infinity) > 1000) { + _isSecondaryViewUserExpanded = false; + } + if (constraints.maxWidth > 1200 && + (_previousConstraints?.maxWidth ?? 0) <= 1200) { + _isSecondaryViewForceExpanded = true; + } + _previousConstraints = constraints; + + final isScrimVisible = + constraints.maxWidth < 1000 && _isSecondaryViewUserExpanded; + + return Row( + children: [ + NavigationRail( + backgroundColor: Colors.transparent, + destinations: [ + const NavigationRailDestination( + icon: Icon(Icons.map_outlined), + selectedIcon: Icon(Icons.map), + label: Text('Map'), ), - 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), + NavigationRailDestination( + icon: Selector( + selector: (context, provider) => + provider.storeName != null, + builder: (context, isDownloading, child) => + !isDownloading ? child! : Badge(child: child), + child: const Icon(Icons.download_outlined), + ), + selectedIcon: const Icon(Icons.download), + label: const Text('Download'), ), - 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, + 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: widget.selectedTab, + labelType: NavigationRailLabelType.all, + leading: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: Column( + children: [ + 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, + ), + ), + ), + if (!_isSecondaryViewForceExpanded) + Padding( + padding: const EdgeInsets.only(bottom: 24), + child: IconButton( + onPressed: () { + setState( + () => _isSecondaryViewUserExpanded = + !_isSecondaryViewUserExpanded, + ); + }, + icon: _isSecondaryViewUserExpanded + ? const Icon(Icons.menu_open) + : const Icon(Icons.menu), + ), + ), + ], ), ), + onDestinationSelected: (i) => selectedTabState.value = i, ), - onDestinationSelected: (i) => selectedTabState.value = 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: Axis.horizontal, + SecondaryViewSide( + selectedTab: widget.selectedTab, + constraints: constraints, + expanded: _isSecondaryViewForceExpanded || + _isSecondaryViewUserExpanded, + ), + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + child: Stack( + children: [ + Positioned.fill( + child: MapView( + bottomSheetOuterController: + widget._bottomSheetOuterController, + mode: widget.mapMode, + layoutDirection: Axis.horizontal, + ), + ), + Positioned.fill( + child: IgnorePointer( + ignoring: !isScrimVisible, + child: GestureDetector( + onTap: () => setState( + () => _isSecondaryViewUserExpanded = false, + ), + child: AnimatedOpacity( + opacity: isScrimVisible ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: const DecoratedBox( + decoration: + BoxDecoration(color: Colors.black), + ), + ), + ), + ), + ), + ], + ), ), ), - ), - ], - ), + ], + ); + }, ), ); } diff --git a/example/lib/src/screens/main/main.dart b/example/lib/src/screens/main/main.dart index 74ea02fd..b1285705 100644 --- a/example/lib/src/screens/main/main.dart +++ b/example/lib/src/screens/main/main.dart @@ -88,7 +88,7 @@ class _MainScreenState extends State { return LayoutBuilder( builder: (context, constraints) { final layoutDirection = - constraints.maxWidth < 1200 ? Axis.vertical : Axis.horizontal; + constraints.maxWidth < 640 ? Axis.vertical : Axis.horizontal; if (layoutDirection == Axis.vertical) { return _VerticalLayout( diff --git a/example/lib/src/screens/main/map_view/components/additional_overlay/fmtc_not_in_use_indicator.dart b/example/lib/src/screens/main/map_view/components/additional_overlay/fmtc_not_in_use_indicator.dart index d2f90a9c..c493e5fc 100644 --- a/example/lib/src/screens/main/map_view/components/additional_overlay/fmtc_not_in_use_indicator.dart +++ b/example/lib/src/screens/main/map_view/components/additional_overlay/fmtc_not_in_use_indicator.dart @@ -11,29 +11,38 @@ class FMTCNotInUseIndicator extends StatelessWidget { final MapViewMode mode; @override - Widget build(BuildContext context) => IgnorePointer( - child: Opacity( - opacity: 2 / 3, - child: Container( - decoration: BoxDecoration( - color: Colors.amber, - borderRadius: BorderRadius.circular(99), - boxShadow: [ - BoxShadow( - color: Colors.grey.withAlpha(255 ~/ 2), - spreadRadius: 6, - blurRadius: 8, + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => IgnorePointer( + child: Opacity( + opacity: 2 / 3, + child: Container( + decoration: BoxDecoration( + color: Colors.amber, + borderRadius: BorderRadius.circular(99), + boxShadow: [ + BoxShadow( + color: Colors.grey.withAlpha(255 ~/ 2), + spreadRadius: 6, + blurRadius: 8, + ), + ], + ), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.hide_image), + if (constraints.maxWidth > 320) ...[ + const SizedBox(width: 8), + const Text('FMTC not in use in this view'), + ], + ], ), - ], - ), - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.hide_image), - SizedBox(width: 8), - Text('FMTC not in use in this view'), - ], + ), ), ), ), 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 41de67d6..7f385044 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 @@ -14,50 +14,93 @@ class SecondaryViewSide extends StatelessWidget { super.key, required this.selectedTab, required this.constraints, + required this.expanded, }); final int selectedTab; final BoxConstraints constraints; + final bool expanded; @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(right: 16, top: 16), - child: SizedBox( - width: (constraints.maxWidth / 3).clamp(440, 560), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - switchInCurve: Curves.easeIn, - switchOutCurve: Curves.easeOut, - transitionBuilder: (child, animation) => ScaleTransition( - scale: Tween(begin: 0.5, end: 1).animate( + Widget build(BuildContext context) { + final child = Padding( + padding: const EdgeInsets.only(right: 16, top: 16), + child: _Contents( + constraints: constraints, + selectedTab: selectedTab, + ), + ); + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) => SizeTransition( + sizeFactor: Tween(begin: 0, end: 1).animate(animation), + axis: Axis.horizontal, + axisAlignment: -1, + child: child, + ), + layoutBuilder: (currentChild, previousChildren) => Stack( + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: Offstage( + key: ValueKey(expanded), + offstage: !expanded, + child: child, + ), + ); + } +} + +class _Contents extends StatelessWidget { + const _Contents({ + required this.constraints, + required this.selectedTab, + }); + + final BoxConstraints constraints; + final int selectedTab; + + @override + Widget build(BuildContext context) => SizedBox( + width: (constraints.maxWidth / 3).clamp(440, 560), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + transitionBuilder: (child, animation) => ScaleTransition( + scale: Tween(begin: 0.5, end: 1).animate( + CurvedAnimation( + parent: animation, + curve: Curves.fastOutSlowIn, + ), + ), + child: FadeTransition( + opacity: Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: animation, curve: Curves.fastOutSlowIn, ), ), - child: FadeTransition( - opacity: Tween(begin: 0, end: 1).animate( - CurvedAnimation( - parent: animation, - curve: Curves.fastOutSlowIn, - ), - ), - child: child, - ), + child: child, ), - child: switch (selectedTab) { - 0 => HomeViewSide(constraints: constraints), - 1 => context.select((p) => p.isFocused) - ? const DownloadingViewSide() - : context.select( - (p) => p.isDownloadSetupPanelVisible, - ) - ? const DownloadConfigurationViewSide() - : const RegionSelectionViewSide(), - 2 => const RecoveryViewSide(), - _ => Placeholder(key: ValueKey(selectedTab)), - }, ), + child: switch (selectedTab) { + 0 => HomeViewSide(constraints: constraints), + 1 => context.select((p) => p.isFocused) + ? const DownloadingViewSide() + : context.select( + (p) => p.isDownloadSetupPanelVisible, + ) + ? const DownloadConfigurationViewSide() + : const RegionSelectionViewSide(), + 2 => const RecoveryViewSide(), + _ => Placeholder(key: ValueKey(selectedTab)), + }, ), ); } From 5382d1201138a9caf5e067e3f3db0c10e33d9be2 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Fri, 20 Dec 2024 22:45:44 +0100 Subject: [PATCH 79/97] Improved example app --- .../src/screens/main/layouts/horizontal.dart | 32 +++++++++++++++---- example/lib/src/screens/main/main.dart | 1 + 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/example/lib/src/screens/main/layouts/horizontal.dart b/example/lib/src/screens/main/layouts/horizontal.dart index b0bdda8b..840170b1 100644 --- a/example/lib/src/screens/main/layouts/horizontal.dart +++ b/example/lib/src/screens/main/layouts/horizontal.dart @@ -115,7 +115,12 @@ class _HorizontalLayoutState extends State<_HorizontalLayout> { ], ), ), - onDestinationSelected: (i) => selectedTabState.value = i, + onDestinationSelected: (i) { + selectedTabState.value = i; + if (!_isSecondaryViewUserExpanded) { + setState(() => _isSecondaryViewUserExpanded = true); + } + }, ), SecondaryViewSide( selectedTab: widget.selectedTab, @@ -132,11 +137,26 @@ class _HorizontalLayoutState extends State<_HorizontalLayout> { child: Stack( children: [ Positioned.fill( - child: MapView( - bottomSheetOuterController: - widget._bottomSheetOuterController, - mode: widget.mapMode, - layoutDirection: Axis.horizontal, + child: TweenAnimationBuilder( + tween: Tween( + begin: 0, + end: isScrimVisible ? 8 : 0, + ), + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + builder: (context, sigma, child) => ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: sigma, + sigmaY: sigma, + ), + child: child, + ), + child: MapView( + bottomSheetOuterController: + widget._bottomSheetOuterController, + mode: widget.mapMode, + layoutDirection: Axis.horizontal, + ), ), ), Positioned.fill( diff --git a/example/lib/src/screens/main/main.dart b/example/lib/src/screens/main/main.dart index b1285705..3380cc19 100644 --- a/example/lib/src/screens/main/main.dart +++ b/example/lib/src/screens/main/main.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math'; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; From 20790996abaf493fc6853db25fc62a2c6c365e8c Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 21 Dec 2024 21:14:24 +0100 Subject: [PATCH 80/97] Improved example app --- ...eyscale_masker.dart => render_object.dart} | 193 ++++++++++++------ .../download_progress_masker.dart | 23 ++- .../region_selection/region_shape.dart | 123 ++++++----- .../src/screens/main/map_view/map_view.dart | 60 +++--- .../components/progress/indicator_text.dart | 67 ++++-- .../components/tiles/unspecified_tile.dart | 16 +- .../src/shared/state/download_provider.dart | 10 +- .../image_provider/image_provider.dart | 15 +- 8 files changed, 318 insertions(+), 189 deletions(-) rename example/lib/src/screens/main/map_view/components/download_progress/components/{greyscale_masker.dart => render_object.dart} (69%) 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/render_object.dart similarity index 69% rename from example/lib/src/screens/main/map_view/components/download_progress/components/greyscale_masker.dart rename to example/lib/src/screens/main/map_view/components/download_progress/components/render_object.dart index b1f403b0..f135f916 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/render_object.dart @@ -1,16 +1,18 @@ part of '../download_progress_masker.dart'; -class GreyscaleMasker extends SingleChildRenderObjectWidget { - const GreyscaleMasker({ +class DownloadProgressMaskerRenderObject extends SingleChildRenderObjectWidget { + const DownloadProgressMaskerRenderObject({ super.key, - required super.child, + required this.isVisible, required this.latestTileCoordinates, required this.mapCamera, required this.minZoom, required this.maxZoom, required this.tileSize, + required super.child, }); + final bool isVisible; final TileCoordinates? latestTileCoordinates; final MapCamera mapCamera; final int minZoom; @@ -19,7 +21,8 @@ class GreyscaleMasker extends SingleChildRenderObjectWidget { @override RenderObject createRenderObject(BuildContext context) => - _GreyscaleMaskerRenderer( + _DownloadProgressMaskerRenderer( + isVisible: isVisible, mapCamera: mapCamera, minZoom: minZoom, maxZoom: maxZoom, @@ -30,17 +33,20 @@ class GreyscaleMasker extends SingleChildRenderObjectWidget { void updateRenderObject( BuildContext context, // ignore: library_private_types_in_public_api - _GreyscaleMaskerRenderer renderObject, + _DownloadProgressMaskerRenderer renderObject, ) { - renderObject.mapCamera = mapCamera; + renderObject + ..mapCamera = mapCamera + ..isVisible = isVisible; if (latestTileCoordinates case final ltc?) renderObject.addTile(ltc); // We don't support changing the other properties. They should not change // during a download. } } -class _GreyscaleMaskerRenderer extends RenderProxyBox { - _GreyscaleMaskerRenderer({ +class _DownloadProgressMaskerRenderer extends RenderProxyBox { + _DownloadProgressMaskerRenderer({ + required bool isVisible, required MapCamera mapCamera, required this.minZoom, required this.maxZoom, @@ -50,7 +56,8 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { 'Unable to work with the large numbers that result from handling the ' 'difference of `maxZoom` & `minZoom`', ), - _mapCamera = mapCamera { + _mapCamera = mapCamera, + _isVisible = isVisible { // Precalculate for more efficient greyscale amount calculations later _maxSubtilesCountPerZoomLevel = Uint64List((maxZoom - minZoom) + 1); int p = 0; @@ -63,17 +70,23 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { //! PROPERTIES + bool _isVisible; + bool get isVisible => _isVisible; + set isVisible(bool value) { + if (value == isVisible) return; + _isVisible = value; + markNeedsPaint(); + } + MapCamera _mapCamera; MapCamera get mapCamera => _mapCamera; set mapCamera(MapCamera value) { if (value == mapCamera) return; _mapCamera = value; - _recompileGreyscalePathCache(); + _recompileEffectLevelPathCache(); markNeedsPaint(); } - TileCoordinates? _prevTile; - /// Minimum zoom level of the download /// /// The difference of [maxZoom] & [minZoom] must be less than 32, due to @@ -89,8 +102,14 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /// Size of each tile in pixels final int tileSize; + /// Maximum amount of blur effect + static const double _maxBlurSigma = 10; + //! STATE + TileCoordinates? _prevTile; + Rect Function()? _mostRecentTile; + /// Maps tiles of a download to a [_TileMappingValue], which contains: /// * the number of subtiles downloaded /// * the lat/lng coordinates of the tile's top-left (North-West) & @@ -111,19 +130,21 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /// The number of subtiles a tile at the zoom level (index) may have late final Uint64List _maxSubtilesCountPerZoomLevel; - /// Cache for a greyscale amount to the path that should be painted with that - /// greyscale level + /// Cache for effect percentages to the path that should be painted with that + /// effect percentage + /// + /// Effect percentage means both greyscale percentage and blur amount. /// - /// The key is multiplied by 1/[_greyscaleLevelsCount] to give the greyscale - /// percentage. This means there are [_greyscaleLevelsCount] levels of - /// greyscale available. Because the difference between close greyscales is - /// very difficult to percieve with the eye, this is acceptable, and improves - /// performance drastically. The ideal amount is calculated and rounded to the - /// nearest level. - final Map _greyscalePathCache = Map.unmodifiable({ - for (int i = 0; i <= _greyscaleLevelsCount; i++) i: Path(), + /// The key is multiplied by 1/[_effectLevelsCount] to give the effect + /// percentage. This means there are [_effectLevelsCount] levels of + /// effects available. Because the difference between close greyscales and + /// blurs is very difficult to percieve with the eye, this is acceptable, and + /// improves performance drastically. The ideal amount is calculated and + /// rounded to the nearest level. + final Map _effectLevelPathCache = Map.unmodifiable({ + for (int i = 0; i <= _effectLevelsCount; i++) i: Path(), }); - static const _greyscaleLevelsCount = 25; + static const _effectLevelsCount = 25; //! GREYSCALE HANDLING @@ -161,9 +182,9 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /// Calculate the greyscale level given the number of subtiles actually /// downloaded and the possible number of subtiles /// - /// Multiply by 1/[_greyscaleLevelsCount] to pass to [_generateGreyscaleFilter] + /// Multiply by 1/[_effectLevelsCount] to pass to [_generateGreyscaleFilter] /// to generate [ColorFilter]. - int _calculateGreyscaleLevel(int subtilesCount, int maxSubtilesCount) { + int _calculateEffectLevel(int subtilesCount, int maxSubtilesCount) { assert( subtilesCount <= maxSubtilesCount, '`subtilesCount` must be less than or equal to `maxSubtilesCount`', @@ -171,8 +192,8 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { final invGreyscalePercentage = (subtilesCount + 1) / (maxSubtilesCount + 1); // +1 to count self - return _greyscaleLevelsCount - - (invGreyscalePercentage * _greyscaleLevelsCount).round(); + return _effectLevelsCount - + (invGreyscalePercentage * _effectLevelsCount).round(); } //! INPUT STREAM HANDLING @@ -214,13 +235,13 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { } /// Handles incoming tiles from the input stream, modifying the [_tileMapping] - /// and [_greyscalePathCache] as necessary + /// and [_effectLevelPathCache] as necessary /// /// Tiles are pruned from the tile mapping where the parent tile has maxed out /// the number of subtiles (ie. all this tile's neighbours within the quad of /// the parent are also downloaded), to save memory space. However, it is /// not possible to prune the path cache, so this will slowly become - /// out-of-sync and less efficient. See [_recompileGreyscalePathCache] + /// out-of-sync and less efficient. See [_recompileEffectLevelPathCache] /// for details. void addTile(TileCoordinates tile) { assert(tile.z >= minZoom, 'Incoming `tile` has zoom level below minimum'); @@ -254,10 +275,12 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { seCoord: mapCamera.crs .pointToLatLng((tile + const Point(1, 1)) * tileSize, zoom), ); + _mostRecentTile = + () => _calculateRectOfCoords(tmv.nwCoord, tmv.seCoord); } - _greyscalePathCache[ - _calculateGreyscaleLevel(tmv.subtilesCount, maxSubtilesCount)]! + _effectLevelPathCache[ + _calculateEffectLevel(tmv.subtilesCount, maxSubtilesCount)]! .addRect(_calculateRectOfCoords(tmv.nwCoord, tmv.seCoord)); late final isParentMaxedOut = _tileMapping[TileCoordinates( @@ -270,35 +293,34 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { intermediateZoomTile.z - 1 - minZoom] - 1; if (intermediateZoomTile.z != minZoom && isParentMaxedOut) { - _tileMapping.remove(intermediateZoomTile); // self - - if (intermediateZoomTile.x.isOdd) { - _tileMapping.remove( + // Remove adjacent tiles in quad + _tileMapping + ..remove(intermediateZoomTile) // self + ..remove( TileCoordinates( - intermediateZoomTile.x - 1, + intermediateZoomTile.x + + (intermediateZoomTile.x.isOdd ? -1 : 1), intermediateZoomTile.y, intermediateZoomTile.z, ), - ); - } - if (intermediateZoomTile.y.isOdd) { - _tileMapping.remove( + ) + ..remove( TileCoordinates( intermediateZoomTile.x, - intermediateZoomTile.y - 1, + intermediateZoomTile.y + + (intermediateZoomTile.y.isOdd ? -1 : 1), intermediateZoomTile.z, ), - ); - } - if (intermediateZoomTile.x.isOdd && intermediateZoomTile.y.isOdd) { - _tileMapping.remove( + ) + ..remove( TileCoordinates( - intermediateZoomTile.x - 1, - intermediateZoomTile.y - 1, + intermediateZoomTile.x + + (intermediateZoomTile.x.isOdd ? -1 : 1), + intermediateZoomTile.y + + (intermediateZoomTile.y.isOdd ? -1 : 1), intermediateZoomTile.z, ), ); - } } }, ); @@ -306,7 +328,7 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { markNeedsPaint(); } - /// Recompile the [_greyscalePathCache] ready for repainting based on the + /// Recompile the [_effectLevelPathCache] ready for repainting based on the /// single source-of-truth of the [_tileMapping] /// /// --- @@ -334,8 +356,8 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { /// /// This method does not call [markNeedsPaint], the caller should perform that /// if necessary. - void _recompileGreyscalePathCache() { - for (final path in _greyscalePathCache.values) { + void _recompileEffectLevelPathCache() { + for (final path in _effectLevelPathCache.values) { path.reset(); } @@ -343,7 +365,7 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { key: TileCoordinates(z: tileZoom), value: _TileMappingValue(:subtilesCount, :nwCoord, :seCoord), ) in _tileMapping.entries) { - _greyscalePathCache[_calculateGreyscaleLevel( + _effectLevelPathCache[_calculateEffectLevel( subtilesCount, _maxSubtilesCountPerZoomLevel[tileZoom - minZoom], )]! @@ -363,42 +385,63 @@ class _GreyscaleMaskerRenderer extends RenderProxyBox { @override void paint(PaintingContext context, Offset offset) { - // Paint the map in greyscale + if (!isVisible) return super.paint(context, offset); + + // Paint the map in full greyscale & blur context.pushColorFilter( offset, _generateGreyscaleFilter(1), - (context, offset) => context.paintChild(child!, offset), + (context, offset) => context.pushImageFilter( + offset, + ImageFilter.blur(sigmaX: _maxBlurSigma, sigmaY: _maxBlurSigma), + (context, offset) => context.paintChild(child!, offset), + ), ); - // Then paint, from colorest to greyscalist (high to low zoom level), each - // layer using the respective `Path` as a clip ('cut') + // Then paint, from lowest effect to highest effect (high to low zoom level), + // each layer using the respective `Path` as a clip int layerHandleIndex = 0; - for (int i = _greyscalePathCache.length - 1; i >= 0; i--) { - final MapEntry(key: greyscaleAmount, value: path) = - _greyscalePathCache.entries.elementAt(i); + for (int i = _effectLevelPathCache.length - 1; i >= 0; i--) { + final MapEntry(key: effectLevel, value: path) = + _effectLevelPathCache.entries.elementAt(i); - final greyscalePercentage = greyscaleAmount * 1 / _greyscaleLevelsCount; + final effectPercentage = effectLevel / _effectLevelsCount; _layerHandles.elementAt(layerHandleIndex).layer = context.pushColorFilter( offset, - _generateGreyscaleFilter(greyscalePercentage), - (context, offset) => context.pushClipPath( - needsCompositing, + _generateGreyscaleFilter(effectPercentage), + (context, offset) => context.pushImageFilter( offset, - Offset.zero & size, - path, - (context, offset) => context.paintChild(child!, offset), - clipBehavior: Clip.hardEdge, + ImageFilter.blur( + sigmaX: effectPercentage * _maxBlurSigma, + sigmaY: effectPercentage * _maxBlurSigma, + ), + (context, offset) => context.pushClipPath( + needsCompositing, + offset, + Offset.zero & size, + path, + (context, offset) => context.paintChild(child!, offset), + clipBehavior: Clip.hardEdge, + ), ), oldLayer: _layerHandles.elementAt(layerHandleIndex).layer, ); layerHandleIndex++; } + + // Paint green 50% overlay over latest tile + if (_mostRecentTile case final rect?) { + context.canvas.drawPath( + Path()..addRect(rect()), + Paint()..color = Colors.green.withAlpha(255 ~/ 2), + ); + } } } -/// See [_GreyscaleMaskerRenderer._tileMapping] for documentation +/// See [_DownloadProgressMaskerRenderer._tileMapping] for documentation /// /// Is mutable to improve performance. class _TileMappingValue { @@ -412,3 +455,17 @@ class _TileMappingValue { final LatLng nwCoord; final LatLng seCoord; } + +extension on PaintingContext { + ImageFilterLayer pushImageFilter( + Offset offset, + ImageFilter imageFilter, + PaintingContextCallback painter, { + ImageFilterLayer? oldLayer, + }) { + final ImageFilterLayer layer = (oldLayer ?? ImageFilterLayer()) + ..imageFilter = imageFilter; + pushLayer(layer, painter, offset); + return layer; + } +} 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 676d6be2..597cc019 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 @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:math'; import 'dart:typed_data'; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -9,11 +10,12 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:latlong2/latlong.dart' hide Path; -part 'components/greyscale_masker.dart'; +part 'components/render_object.dart'; class DownloadProgressMasker extends StatefulWidget { const DownloadProgressMasker({ super.key, + required this.isVisible, required this.tileEvents, required this.minZoom, required this.maxZoom, @@ -21,26 +23,30 @@ class DownloadProgressMasker extends StatefulWidget { required this.child, }); + final bool isVisible; final Stream? tileEvents; final int minZoom; final int maxZoom; final int tileSize; final TileLayer child; + // To reset after a download, the `key` must be changed + @override State createState() => _DownloadProgressMaskerState(); } class _DownloadProgressMaskerState extends State { @override - Widget build(BuildContext context) { - if (widget.tileEvents case final tileEvents?) { - return RepaintBoundary( + Widget build(BuildContext context) => RepaintBoundary( child: StreamBuilder( - stream: tileEvents - .where((evt) => evt is SuccessfulTileEvent) + stream: widget.tileEvents + ?.where( + (evt) => evt is SuccessfulTileEvent || evt is SkippedTileEvent, + ) .map((evt) => evt.coordinates), - builder: (context, coords) => GreyscaleMasker( + builder: (context, coords) => DownloadProgressMaskerRenderObject( + isVisible: widget.isVisible, mapCamera: MapCamera.of(context), latestTileCoordinates: coords.data == null ? null @@ -56,7 +62,4 @@ class _DownloadProgressMaskerState extends State { ), ), ); - } - return widget.child; - } } diff --git a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart index 0e8f475c..ec094739 100644 --- a/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart +++ b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart @@ -4,12 +4,18 @@ 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_provider.dart'; import '../../../../../shared/state/general_provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; -class RegionShape extends StatelessWidget { +class RegionShape extends StatefulWidget { const RegionShape({super.key}); + @override + State createState() => _RegionShapeState(); +} + +class _RegionShapeState extends State { @override Widget build(BuildContext context) => Consumer( builder: (context, provider, _) { @@ -84,51 +90,72 @@ class RegionShape extends StatelessWidget { }, ); - Widget _renderConstructedRegion(BaseRegion region, HSLColor color) => - switch (region) { - RectangleRegion(:final bounds) => PolygonLayer( - polygons: [ - Polygon( - points: [ - bounds.northWest, - bounds.northEast, - bounds.southEast, - bounds.southWest, - ], - color: color.toColor().withAlpha(255 ~/ 2), - ), - ], - ), - CircleRegion(:final center, :final radius) => CircleLayer( - circles: [ - CircleMarker( - point: center, - radius: radius * 1000, - useRadiusInMeter: true, - color: color.toColor().withAlpha(255 ~/ 2), - ), - ], - ), - LineRegion() => PolygonLayer( - polygons: region - .toOutlines(1) - .map( - (o) => Polygon( - points: o, - color: color.toColor().withAlpha(255 ~/ 2), - ), - ) - .toList(growable: false), - ), - CustomPolygonRegion(:final outline) => PolygonLayer( - polygons: [ - Polygon( - points: outline, - color: color.toColor().withAlpha(255 ~/ 2), - ), - ], - ), - MultiRegion() => - throw UnsupportedError('Cannot support `MultiRegion`s here'), - }; + Widget _renderConstructedRegion(BaseRegion region, HSLColor color) { + final isDownloading = + context.watch().storeName != null; + + return switch (region) { + RectangleRegion(:final bounds) => PolygonLayer( + polygons: [ + Polygon( + points: [ + bounds.northWest, + bounds.northEast, + bounds.southEast, + bounds.southWest, + ], + color: isDownloading + ? Colors.transparent + : color.toColor().withAlpha(255 ~/ 2), + borderColor: isDownloading ? Colors.black : Colors.transparent, + borderStrokeWidth: 3, + ), + ], + ), + CircleRegion(:final center, :final radius) => CircleLayer( + circles: [ + CircleMarker( + point: center, + radius: radius * 1000, + useRadiusInMeter: true, + color: isDownloading + ? Colors.transparent + : color.toColor().withAlpha(255 ~/ 2), + borderColor: isDownloading ? Colors.black : Colors.transparent, + borderStrokeWidth: 3, + ), + ], + ), + LineRegion() => PolygonLayer( + polygons: region + .toOutlines(1) + .map( + (o) => Polygon( + points: o, + color: isDownloading + ? Colors.transparent + : color.toColor().withAlpha(255 ~/ 2), + borderColor: + isDownloading ? Colors.black : Colors.transparent, + borderStrokeWidth: 3, + ), + ) + .toList(growable: false), + ), + CustomPolygonRegion(:final outline) => PolygonLayer( + polygons: [ + Polygon( + points: outline, + color: isDownloading + ? Colors.transparent + : color.toColor().withAlpha(255 ~/ 2), + borderColor: isDownloading ? Colors.black : Colors.transparent, + borderStrokeWidth: 3, + ), + ], + ), + MultiRegion() => + throw UnsupportedError('Cannot support `MultiRegion`s here'), + }; + } } 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 6a77633c..006cb388 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -1,4 +1,4 @@ -//import 'dart:async'; +import 'dart:async'; import 'dart:io'; import 'dart:math'; @@ -13,12 +13,12 @@ import 'package:provider/provider.dart'; import '../../../shared/misc/shared_preferences.dart'; import '../../../shared/misc/store_metadata_keys.dart'; -//import '../../../shared/state/download_provider.dart'; +import '../../../shared/state/download_provider.dart'; import '../../../shared/state/general_provider.dart'; 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/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'; @@ -334,40 +334,40 @@ class _MapViewState extends State with TickerProviderStateMixin { ), ); - //final isDownloadProgressMaskerVisible = widget.mode == - // MapViewMode.downloadRegion && - // context.select((p) => p.isFocused); + final isDownloadProgressMaskerVisible = widget.mode == + MapViewMode.downloadRegion && + context.select((p) => p.isFocused); final map = FlutterMap( mapController: _mapController.mapController, options: mapOptions, children: [ - /*DownloadProgressMasker( - key: ObjectKey( - isDownloadProgressMaskerVisible - ? context - .select( - (p) => p.downloadableRegion, - ) - : null, + DownloadProgressMasker( + key: ValueKey( + context.select( + (p) => p.storeName != null + ? p.downloadableRegion.originalRegion + : null, + ), + ), + isVisible: isDownloadProgressMaskerVisible && + context.select( + (provider) => provider.useMaskEffect, + ), + tileEvents: + context.select?>( + (p) => p.storeName != null ? p.rawTileEventStream : null, + ), + minZoom: context.select( + (p) => + p.storeName != null ? p.downloadableRegion.minZoom : 0, + ), + maxZoom: context.select( + (p) => + p.storeName != null ? p.downloadableRegion.maxZoom : 20, ), - tileEvents: isDownloadProgressMaskerVisible - ? context.select>((p) => p.rawTileEventStream) - : null, - minZoom: isDownloadProgressMaskerVisible - ? context.select( - (p) => p.downloadableRegion.minZoom, - ) - : 0, - maxZoom: isDownloadProgressMaskerVisible - ? context.select( - (p) => p.downloadableRegion.maxZoom, - ) - : 0, child: tileLayer, - ),*/ - tileLayer, + ), if (widget.mode == MapViewMode.downloadRegion) ...[ const RegionShape(), const CustomPolygonSnappingIndicator(), diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart index c2dcd8df..ffaddbf4 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart @@ -79,26 +79,55 @@ class _ProgressIndicatorTextState extends State { return Column( children: [ - Align( - alignment: Alignment.centerRight, - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: false, - icon: Icon(Icons.numbers), - tooltip: 'Show tile counts', - ), - ButtonSegment( - value: true, - icon: Icon(Icons.percent), - tooltip: 'Show percentages', + Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 12, + children: [ + Tooltip( + message: 'Use mask effect', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6) + + const EdgeInsets.only(left: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceDim, + borderRadius: BorderRadius.circular(99), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + const Icon(Icons.gradient), + Switch.adaptive( + value: context.select( + (provider) => provider.useMaskEffect, + ), + onChanged: (val) => context + .read() + .useMaskEffect = val, + ), + ], + ), ), - ], - selected: {_usePercentages}, - onSelectionChanged: (v) => - setState(() => _usePercentages = v.single), - showSelectedIcon: false, - ), + ), + SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.numbers), + tooltip: 'Show tile counts', + ), + ButtonSegment( + value: true, + icon: Icon(Icons.percent), + tooltip: 'Show percentages', + ), + ], + selected: {_usePercentages}, + onSelectionChanged: (v) => + setState(() => _usePercentages = v.single), + showSelectedIcon: false, + ), + ], ), const SizedBox(height: 8), _TextRow( diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart index 7e9e8513..16f6907f 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart @@ -51,14 +51,14 @@ class _UnspecifiedTileState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceDim, - borderRadius: BorderRadius.circular(99), - ), - child: Tooltip( - message: 'Use as fallback only', + Tooltip( + message: 'Use as fallback only', + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceDim, + borderRadius: BorderRadius.circular(99), + ), child: Row( children: [ const Icon(Icons.last_page), diff --git a/example/lib/src/shared/state/download_provider.dart b/example/lib/src/shared/state/download_provider.dart index b23191c8..81535d64 100644 --- a/example/lib/src/shared/state/download_provider.dart +++ b/example/lib/src/shared/state/download_provider.dart @@ -64,7 +64,7 @@ class DownloadingProvider extends ChangeNotifier { }, ); - downloadStreams.tileEvents.listen((evt) { + _rawTileEventsStream!.listen((evt) { // Update stored value _latestTileEvent = evt; notifyListeners(); @@ -96,10 +96,18 @@ class DownloadingProvider extends ChangeNotifier { _isFocused = false; _isComplete = false; _storeName = null; + _downloadableRegion = null; notifyListeners(); } StateError get _notReadyError => StateError( 'Unsafe to retrieve information before a download has been assigned.', ); + + bool _useMaskEffect = true; + bool get useMaskEffect => _useMaskEffect; + set useMaskEffect(bool newState) { + _useMaskEffect = newState; + notifyListeners(); + } } diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index d9200554..bb2d350a 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -142,16 +142,21 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { @override Future<_FMTCImageProvider> obtainKey(ImageConfiguration configuration) => - SynchronousFuture<_FMTCImageProvider>(this); + SynchronousFuture(this); @override bool operator ==(Object other) => identical(this, other) || (other is _FMTCImageProvider && - other.coords == coords && - other.provider == provider && - other.options == options); + other.coords == + coords /*&& + other.provider == provider && + other.options == options*/ + ); @override - int get hashCode => Object.hash(coords, provider, options); + int get hashCode => coords + .hashCode; /*Object.hash( + coords, /*, provider, options*/ + );*/ } From 1a2658792326a3b04e8392e01b7b20b1b6cdc8f6 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 29 Dec 2024 16:59:52 +0000 Subject: [PATCH 81/97] Updated example app Android build config Updated lint set & fixed lints --- example/android/app/build.gradle | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle | 2 +- example/lib/main.dart | 3 ++ .../debugging_tile_builder/info_display.dart | 4 +-- .../components/render_object.dart | 5 +-- .../src/screens/main/map_view/map_view.dart | 1 + .../components/toggle_option.dart | 1 + .../confirmation_panel.dart | 9 +++-- .../components/progress/indicator_text.dart | 36 +++++++++---------- .../components/tile_display/tile_display.dart | 3 +- ...lumn_headers_and_inheritable_settings.dart | 2 +- .../components/export_stores/button.dart | 1 + .../browse_store_strategy_selector.dart | 1 + .../checkbox.dart | 3 +- .../dropdown.dart | 4 +-- .../components/no_regions.dart | 4 +-- .../animated_visibility_icon_button.dart | 3 ++ .../shape_selector/shape_selector.dart | 3 +- .../screens/store_editor/store_editor.dart | 6 ++-- .../src/shared/components/url_selector.dart | 2 +- .../src/shared/misc/exts/size_formatter.dart | 3 +- jaffa_lints.yaml | 12 ++++++- lib/custom_backend_api.dart | 2 +- lib/flutter_map_tile_caching.dart | 2 +- lib/src/backend/errors/import_export.dart | 8 ++--- .../internal_workers/standard/worker.dart | 15 ++++++-- .../backend/interfaces/backend/internal.dart | 19 +++++----- .../backend/internal_thread_safe.dart | 3 ++ lib/src/backend/interfaces/models.dart | 3 ++ .../external/download_progress.dart | 4 +-- lib/src/bulk_download/internal/instance.dart | 7 ---- lib/src/bulk_download/internal/manager.dart | 4 +++ .../internal/rate_limited_stream.dart | 7 ++-- lib/src/bulk_download/internal/thread.dart | 2 ++ .../internal/tile_loops/count.dart | 20 +++++------ .../internal/tile_loops/generate.dart | 27 ++++++++------ .../internal/tile_loops/shared.dart | 1 + .../image_provider/browsing_errors.dart | 8 ++--- .../image_provider/image_provider.dart | 23 +++++------- .../image_provider/internal_get_bytes.dart | 13 ++++--- .../tile_loading_interceptor/result.dart | 11 +++--- .../providers/tile_provider/strategies.dart | 12 +++---- .../tile_provider/tile_provider.dart | 35 +++++++++--------- lib/src/regions/downloadable_region.dart | 1 + lib/src/regions/recovered_region.dart | 3 +- lib/src/regions/shapes/multi.dart | 3 +- lib/src/root/recovery.dart | 10 ++---- lib/src/root/root.dart | 4 +-- lib/src/store/download.dart | 19 +++++----- lib/src/store/statistics.dart | 8 ++--- lib/src/store/store.dart | 3 +- test/region_tile_test.dart | 1 + tile_server/bin/tile_server.dart | 10 ++++-- 54 files changed, 229 insertions(+), 171 deletions(-) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 8e644b93..f7cc2822 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -9,7 +9,7 @@ android { namespace = "dev.jaffaketchup.fmtc.demo" compileSdk = flutter.compileSdkVersion // ndkVersion = flutter.ndkVersion - ndkVersion = "26.1.10909125" + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 3c85cfe0..348c409e 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index d3bb611e..ebe6f9c1 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.5.2' apply false + id "com.android.application" version '8.7.3' apply false id "org.jetbrains.kotlin.android" version "1.8.22" apply false } diff --git a/example/lib/main.dart b/example/lib/main.dart index a758d012..ca79325b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -24,6 +24,9 @@ void main() async { Object? initErr; try { await FMTCObjectBoxBackend().initialise(); + // We don't know what errors will be thrown, we want to handle them all + // later + // ignore: avoid_catches_without_on_clauses } catch (err) { initErr = err; } diff --git a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart index 002b8309..db6d6b8a 100644 --- a/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart +++ b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart @@ -36,11 +36,11 @@ class _ResultDisplay extends StatelessWidget { ), if (fmtcResult.resultPath case final result?) ...[ Text( - '`${result.name}` in ${tile.loadFinishedAt == null || tile.loadStarted == null ? '...' : tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms', + '''`${result.name}` in ${tile.loadFinishedAt == null || tile.loadStarted == null ? '...' : tile.loadFinishedAt!.difference(tile.loadStarted!).inMilliseconds} ms''', textAlign: TextAlign.center, ), Text( - '(${fmtcResult.cacheFetchDuration.inMilliseconds} ms cache${fmtcResult.networkFetchDuration == null ? ')' : ' | ${fmtcResult.networkFetchDuration!.inMilliseconds} ms network)'}\n', + '''(${fmtcResult.cacheFetchDuration.inMilliseconds} ms cache${fmtcResult.networkFetchDuration == null ? ')' : ' | ${fmtcResult.networkFetchDuration!.inMilliseconds} ms network)'}\n''', textAlign: TextAlign.center, ), Row( diff --git a/example/lib/src/screens/main/map_view/components/download_progress/components/render_object.dart b/example/lib/src/screens/main/map_view/components/download_progress/components/render_object.dart index f135f916..1304f6b7 100644 --- a/example/lib/src/screens/main/map_view/components/download_progress/components/render_object.dart +++ b/example/lib/src/screens/main/map_view/components/download_progress/components/render_object.dart @@ -32,6 +32,7 @@ class DownloadProgressMaskerRenderObject extends SingleChildRenderObjectWidget { @override void updateRenderObject( BuildContext context, + // It will only ever be this private type, called internally by Flutter // ignore: library_private_types_in_public_api _DownloadProgressMaskerRenderer renderObject, ) { @@ -398,8 +399,8 @@ class _DownloadProgressMaskerRenderer extends RenderProxyBox { ), ); - // Then paint, from lowest effect to highest effect (high to low zoom level), - // each layer using the respective `Path` as a clip + // Then paint, from lowest effect to highest effect (high to low zoom + // level), each layer using the respective `Path` as a clip int layerHandleIndex = 0; for (int i = _effectLevelPathCache.length - 1; i >= 0; i--) { final MapEntry(key: effectLevel, value: path) = 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 006cb388..ec53dc6b 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -321,6 +321,7 @@ class _MapViewState extends State with TickerProviderStateMixin { recordHitsAndMisses: false, tileLoadingInterceptor: _tileLoadingDebugger, httpClient: _httpClient, + // This is the intended purpose // ignore: invalid_use_of_visible_for_testing_member fakeNetworkDisconnect: provider.fakeNetworkDisconnect, ), diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/toggle_option.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/toggle_option.dart index 477028dd..36007d5e 100644 --- a/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/toggle_option.dart +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/toggle_option.dart @@ -13,6 +13,7 @@ class _ToggleOption extends StatelessWidget { final String title; final String description; final bool value; + // Parameter meaning obvious from context, also callback // ignore: avoid_positional_boolean_parameters final void Function(bool value) onChanged; 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 73c5bb84..ef1eeb58 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 @@ -163,11 +163,10 @@ class _ConfirmationPanelState extends State { 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.', + '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.', ), ], ), diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart index ffaddbf4..7e8910c3 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart @@ -134,65 +134,65 @@ class _ProgressIndicatorTextState extends State { color: DownloadingProgressIndicatorColors.successfulColor, type: 'Successful', statistic: _usePercentages - ? '${(((successfulFlushedTilesCount + successfulBufferedTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}% ' - : '${successfulFlushedTilesCount + successfulBufferedTilesCount} tiles (${(successfulFlushedTilesSize + successfulBufferedTilesSize).asReadableSize})', + ? '''${(((successfulFlushedTilesCount + successfulBufferedTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}% ''' + : '''${successfulFlushedTilesCount + successfulBufferedTilesCount} tiles (${(successfulFlushedTilesSize + successfulBufferedTilesSize).asReadableSize})''', ), const SizedBox(height: 4), _TextRow( type: 'Flushed', statistic: _usePercentages - ? '${((successfulFlushedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}% ' - : '$successfulFlushedTilesCount tiles (${successfulFlushedTilesSize.asReadableSize})', + ? '''${((successfulFlushedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}% ''' + : '''$successfulFlushedTilesCount tiles (${successfulFlushedTilesSize.asReadableSize})''', ), const SizedBox(height: 4), _TextRow( type: 'Buffered', statistic: _usePercentages - ? '${((successfulBufferedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' - : '$successfulBufferedTilesCount tiles (${successfulBufferedTilesSize.asReadableSize})', + ? '''${((successfulBufferedTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%''' + : '''$successfulBufferedTilesCount tiles (${successfulBufferedTilesSize.asReadableSize})''', ), const SizedBox(height: 4), _TextRow( color: DownloadingProgressIndicatorColors.skippedColor, type: 'Skipped', statistic: _usePercentages - ? '${(((skippedSeaTilesCount + skippedExistingTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}%' - : '${skippedSeaTilesCount + skippedExistingTilesCount} tiles (${(skippedSeaTilesSize + skippedExistingTilesSize).asReadableSize})', + ? '''${(((skippedSeaTilesCount + skippedExistingTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}%''' + : '''${skippedSeaTilesCount + skippedExistingTilesCount} tiles (${(skippedSeaTilesSize + skippedExistingTilesSize).asReadableSize})''', ), const SizedBox(height: 4), _TextRow( type: 'Existing', statistic: _usePercentages - ? '${((skippedExistingTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' - : '$skippedExistingTilesCount tiles (${skippedExistingTilesSize.asReadableSize})', + ? '''${((skippedExistingTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%''' + : '''$skippedExistingTilesCount tiles (${skippedExistingTilesSize.asReadableSize})''', ), const SizedBox(height: 4), _TextRow( type: 'Sea Tiles', statistic: _usePercentages - ? '${((skippedSeaTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' - : '$skippedSeaTilesCount tiles (${skippedSeaTilesSize.asReadableSize})', + ? '''${((skippedSeaTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%''' + : '''$skippedSeaTilesCount tiles (${skippedSeaTilesSize.asReadableSize})''', ), const SizedBox(height: 4), _TextRow( color: DownloadingProgressIndicatorColors.failedColor, type: 'Failed', statistic: _usePercentages - ? '${(((failedNegativeResponseTilesCount + failedFailedRequestTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}%' - : '${failedNegativeResponseTilesCount + failedFailedRequestTilesCount} tiles', + ? '''${(((failedNegativeResponseTilesCount + failedFailedRequestTilesCount) / maxTilesCount) * 100).toStringAsFixed(1)}%''' + : '''${failedNegativeResponseTilesCount + failedFailedRequestTilesCount} tiles''', ), const SizedBox(height: 4), _TextRow( type: 'Negative Response', statistic: _usePercentages - ? '${((failedNegativeResponseTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + ? '''${((failedNegativeResponseTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%''' : '$failedNegativeResponseTilesCount tiles', ), const SizedBox(height: 4), _TextRow( type: 'Failed Request', statistic: _usePercentages - ? '${((failedFailedRequestTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + ? '''${((failedFailedRequestTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%''' : '$failedFailedRequestTilesCount tiles', ), const SizedBox(height: 4), @@ -200,7 +200,7 @@ class _ProgressIndicatorTextState extends State { color: DownloadingProgressIndicatorColors.retryQueueColor, type: 'Queued For Retry', statistic: _usePercentages - ? '${((retryTilesQueuedCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + ? '''${((retryTilesQueuedCount / maxTilesCount) * 100).toStringAsFixed(1)}%''' : '$retryTilesQueuedCount tiles', ), const SizedBox(height: 4), @@ -208,7 +208,7 @@ class _ProgressIndicatorTextState extends State { color: DownloadingProgressIndicatorColors.pendingColor, type: 'Pending', statistic: _usePercentages - ? '${((remainingTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%' + ? '''${((remainingTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%''' : '$remainingTilesCount/$maxTilesCount tiles', ), ], diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart index 5244f6a3..b4eb1471 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart @@ -48,8 +48,7 @@ class TileDisplay extends StatelessWidget { is FailedRequestTileEvent ? Icons .signal_wifi_connected_no_internet_4_outlined - : Icons - .signal_cellular_connected_no_internet_4_bar_outlined, + : Icons.broken_image, size: 48, color: Colors.red, ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart index 681adc8f..33be3ce8 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart @@ -99,7 +99,7 @@ class ColumnHeadersAndInheritableSettings extends StatelessWidget { ) .toList(), value: currentBehaviour, - onChanged: (BrowseStoreStrategy? v) => context + onChanged: (v) => context .read() .inheritableBrowseStoreStrategy = v, ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart index 8a9112c1..42a53465 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart @@ -138,6 +138,7 @@ class ExportStoresButton extends StatelessWidget { (await showOverwriteConfirmationDialog(context) ?? false)) { return; } + // We do indeed want a default here // ignore: no_default_cases default: ScaffoldMessenger.maybeOf(context)?.showSnackBar(invalidTypeSnackbar); diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart index a8595ac3..6ef7854a 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart @@ -47,6 +47,7 @@ class BrowseStoreStrategySelector extends StatelessWidget { unspecifiedStrategy != InternalBrowseStoreStrategy.disable && enabled; + // Parameter meaning obvious from context, also callback // ignore: avoid_positional_boolean_parameters void changedInheritCheckbox(bool? value) { final provider = context.read(); diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart index 31ff1e03..20fe60cb 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart @@ -36,7 +36,8 @@ class _BrowseStoreStrategySelectorCheckbox extends StatelessWidget { provider.inheritableBrowseStoreStrategy && !isUnspecifiedSelector) { // Selected same as inherited - // > Automatically enable inheritance (assumed desire, can be undone) + // > Automatically enable inheritance (assumed desire, can be + // undone) provider.currentStores[storeName] = InternalBrowseStoreStrategy.inherit; } else { diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart index 8bfce451..ce5c6392 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart @@ -16,7 +16,7 @@ class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: DropdownButton( + child: DropdownButton( items: [null, ...BrowseStoreStrategy.values].map( (e) { final iconColor = isUnspecifiedSelector @@ -74,7 +74,7 @@ class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { ).toList(), value: currentStrategy, onChanged: enabled - ? (BrowseStoreStrategy? v) { + ? (v) { final provider = context.read(); if (v == null) { 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 index 6fb103d7..738ae13c 100644 --- 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 @@ -20,8 +20,8 @@ class _NoRegions extends StatelessWidget { ), const SizedBox(height: 6), const Text( - "If a download fails unexpectedly, it'll appear here! You can " - 'then finish the end of the download.', + "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/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart index 5b8d46fc..a4821bfa 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart @@ -6,6 +6,7 @@ class _AnimatedVisibilityIconButton extends StatelessWidget { this.onPressed, this.tooltip, required this.isVisible, + // This is exactly what we want to do // ignore: avoid_field_initializers_in_const_classes }) : _mode = 0; @@ -14,6 +15,7 @@ class _AnimatedVisibilityIconButton extends StatelessWidget { this.onPressed, this.tooltip, required this.isVisible, + // This is exactly what we want to do // ignore: avoid_field_initializers_in_const_classes }) : _mode = 1; @@ -22,6 +24,7 @@ class _AnimatedVisibilityIconButton extends StatelessWidget { this.onPressed, this.tooltip, required this.isVisible, + // This is exactly what we want to do // ignore: avoid_field_initializers_in_const_classes }) : _mode = 2; diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart index 3734b4c6..0b7fd79a 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart @@ -102,7 +102,8 @@ class _ShapeSelectorState extends State { ), ), Text( - '${provider.lineRadius.round().toString().padLeft(4, '0')}m', + '${provider.lineRadius.round().toString().padLeft(4, '0')}' + 'm', ), const VerticalDivider(), IconButton.outlined( diff --git a/example/lib/src/screens/store_editor/store_editor.dart b/example/lib/src/screens/store_editor/store_editor.dart index fa20edba..9118d138 100644 --- a/example/lib/src/screens/store_editor/store_editor.dart +++ b/example/lib/src/screens/store_editor/store_editor.dart @@ -107,7 +107,8 @@ class _StoreEditorPopupState extends State { (provider) => provider.urlTemplate, ), helperText: - 'In the example app, stores only contain tiles from one source', + 'In the example app, stores only contain tiles ' + 'from one source', ); }, ), @@ -132,7 +133,8 @@ class _StoreEditorPopupState extends State { validator: (input) { if ((input?.isNotEmpty ?? false) && (int.tryParse(input!) ?? -1) < 0) { - return 'Must be empty, or greater than or equal to 0'; + return 'Must be empty, or greater than or equal ' + 'to 0'; } return null; }, diff --git a/example/lib/src/shared/components/url_selector.dart b/example/lib/src/shared/components/url_selector.dart index 3e9aeea9..94b67277 100644 --- a/example/lib/src/shared/components/url_selector.dart +++ b/example/lib/src/shared/components/url_selector.dart @@ -111,7 +111,7 @@ class _UrlSelectorState extends State { dropdownMenuEntries: _constructMenuEntries(snapshot), onSelected: _onSelected, helperText: 'Use standard placeholders & include protocol' - '${widget.helperText != null ? '\n${widget.helperText}' : ''}', + '''${widget.helperText != null ? '\n${widget.helperText}' : ''}''', focusNode: _dropdownMenuFocusNode, ), ), diff --git a/example/lib/src/shared/misc/exts/size_formatter.dart b/example/lib/src/shared/misc/exts/size_formatter.dart index 97004f09..d3390633 100644 --- a/example/lib/src/shared/misc/exts/size_formatter.dart +++ b/example/lib/src/shared/misc/exts/size_formatter.dart @@ -7,6 +7,7 @@ extension SizeFormatter on num { if (this <= 0) return '0 B'; final List units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; final int digitGroups = log(this) ~/ log(1024); - return '${NumberFormat('#,##0.#').format(this / pow(1024, digitGroups))} ${units[digitGroups]}'; + return '${NumberFormat('#,##0.#').format(this / pow(1024, digitGroups))} ' + '${units[digitGroups]}'; } } diff --git a/jaffa_lints.yaml b/jaffa_lints.yaml index 00d25b89..81dfd034 100644 --- a/jaffa_lints.yaml +++ b/jaffa_lints.yaml @@ -5,14 +5,17 @@ linter: - annotate_redeclares - avoid_annotating_with_dynamic - avoid_bool_literals_in_conditional_expressions + - avoid_catches_without_on_clauses - avoid_catching_errors - avoid_double_and_int_checks - avoid_dynamic_calls - avoid_empty_else + - avoid_equals_and_hash_code_on_mutable_classes - avoid_escaping_inner_quotes - avoid_field_initializers_in_const_classes - avoid_final_parameters - avoid_function_literals_in_foreach_calls + - avoid_futureor_void - avoid_implementing_value_types - avoid_init_to_null - avoid_js_rounded_ints @@ -33,6 +36,7 @@ linter: - avoid_slow_async_io - avoid_type_to_string - avoid_types_as_parameter_names + - avoid_types_on_closure_parameters - avoid_unnecessary_containers - avoid_unused_constructor_parameters - avoid_void_async @@ -57,6 +61,7 @@ linter: - deprecated_member_use_from_same_package - directives_ordering - do_not_use_environment + - document_ignores - empty_catches - empty_constructor_bodies - empty_statements @@ -68,14 +73,17 @@ linter: - implicit_call_tearoffs - implicit_reopen - invalid_case_patterns + - invalid_runtime_check_with_js_interop_types - join_return_with_assignment - leading_newlines_in_multiline_strings - library_annotations - library_names - library_prefixes - library_private_types_in_public_api + - lines_longer_than_80_chars - literal_only_boolean_expressions - matching_super_parameters + - missing_code_block_language_in_doc_comment - missing_whitespace_between_adjacent_strings - no_adjacent_strings_in_list - no_default_cases @@ -154,6 +162,7 @@ linter: - type_init_formals - type_literal_in_constant_pattern - unawaited_futures + - unintended_html_in_doc_comment - unnecessary_await_in_return - unnecessary_brace_in_string_interps - unnecessary_breaks @@ -163,6 +172,7 @@ linter: - unnecessary_lambdas - unnecessary_late - unnecessary_library_directive + - unnecessary_library_name - unnecessary_new - unnecessary_null_aware_assignments - unnecessary_null_aware_operator_on_extension_on_nullable @@ -179,7 +189,6 @@ linter: - unnecessary_to_list_in_spreads - unreachable_from_main - unrelated_type_equality_checks - - unsafe_html - use_build_context_synchronously - use_colored_box - use_decorated_box @@ -199,5 +208,6 @@ linter: - use_super_parameters - use_test_throws_matchers - use_to_and_as_if_applicable + - use_truncating_division - valid_regexps - void_checks \ No newline at end of file diff --git a/lib/custom_backend_api.dart b/lib/custom_backend_api.dart index 0f5dec5e..dfb4b25c 100644 --- a/lib/custom_backend_api.dart +++ b/lib/custom_backend_api.dart @@ -13,6 +13,6 @@ /// > may be limited. Always use the standard import unless necessary. /// /// Importing the standard library will also likely be necessary. -library flutter_map_tile_caching.custom_backend_api; +library; export 'src/backend/export_internal.dart'; diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 82579859..1111774d 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -8,7 +8,7 @@ /// /// * [Documentation Site](https://fmtc.jaffaketchup.dev/) /// * [Full API Reference](https://pub.dev/documentation/flutter_map_tile_caching/latest/flutter_map_tile_caching/flutter_map_tile_caching-library.html) -library flutter_map_tile_caching; +library; import 'dart:async'; import 'dart:collection'; diff --git a/lib/src/backend/errors/import_export.dart b/lib/src/backend/errors/import_export.dart index 8017d10a..b6c42ad6 100644 --- a/lib/src/backend/errors/import_export.dart +++ b/lib/src/backend/errors/import_export.dart @@ -39,8 +39,8 @@ final class ImportPathNotExists extends ImportExportError { /// /// The last bytes of the file must be hex "FF FF 46 4D 54 43" ("**FMTC"). final class ImportFileNotFMTCStandard extends ImportExportError { - /// Indicates that the import file was not of the expected standard, because it - /// did not contain the appropriate footer signature + /// Indicates that the import file was not of the expected standard, because + /// it did not contain the appropriate footer signature /// /// The last bytes of the file must be hex "FF FF 46 4D 54 43" ("**FMTC"). ImportFileNotFMTCStandard(); @@ -60,8 +60,8 @@ final class ImportFileNotBackendCompatible extends ImportExportError { /// Indicates that the import file was exported from a different FMTC backend, /// and is not compatible with the current backend /// - /// The bytes prior to the footer signature should an identifier (eg. the name) - /// of the exporting backend proceeded by hex "FF FF FF FF". + /// The bytes prior to the footer signature should an identifier (eg. the + /// name) of the exporting backend proceeded by hex "FF FF FF FF". ImportFileNotBackendCompatible(); @override diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index 51c49ccf..fa1a0c22 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -43,6 +43,9 @@ Future _worker( if (!rootBox.contains(1)) { rootBox.put(ObjectBoxRoot(length: 0, size: 0), mode: PutMode.insert); } + // We don't know what errors may be thrown, we just want to send them all + // back + // ignore: avoid_catches_without_on_clauses } catch (e, s) { sendRes(id: 0, data: {'error': e, 'stackTrace': s}); Isolate.exit(); @@ -837,7 +840,8 @@ Future _worker( if (streamedOutputSubscriptions[id] == null) { throw StateError( - 'Cannot cancel internal streamed result because none was registered.', + 'Cannot cancel internal streamed result because none was ' + 'registered.', ); } @@ -986,6 +990,9 @@ Future _worker( exportingRoot.close(); try { workingDir.deleteSync(recursive: true); + // If the working dir didn't exist, that's fine + // We don't want to spend time checking if exists, as it likely + // does // ignore: empty_catches } on FileSystemException {} Error.throwWithStackTrace(error, stackTrace); @@ -995,6 +1002,8 @@ Future _worker( exportingRoot.close(); try { workingDir.deleteSync(recursive: true); + // If the working dir didn't exist, that's fine + // We don't want to spend time checking if exists, as it likely does // ignore: empty_catches } on FileSystemException {} Error.throwWithStackTrace(error, stackTrace); @@ -1047,7 +1056,6 @@ Future _worker( } final StoresToStates storesToStates = {}; - // ignore: unnecessary_parenthesis (switch (strategy) { ImportConflictStrategy.skip => importingStoresQuery .stream() @@ -1457,6 +1465,9 @@ Future _worker( await receivePort.listen((cmd) { try { mainHandler(cmd); + // We don't know what errors may be thrown, we just want to send them all + // back + // ignore: avoid_catches_without_on_clauses } catch (e, s) { cmd as _IncomingCmd; diff --git a/lib/src/backend/interfaces/backend/internal.dart b/lib/src/backend/interfaces/backend/internal.dart index caa8589a..c9c26910 100644 --- a/lib/src/backend/interfaces/backend/internal.dart +++ b/lib/src/backend/interfaces/backend/internal.dart @@ -14,10 +14,10 @@ import '../../export_internal.dart'; /// /// Should implement methods that operate in another isolate/thread to avoid /// blocking the normal thread. In this case, [FMTCBackendInternalThreadSafe] -/// should also be implemented, which should not operate in another thread & must -/// be sendable between isolates (because it will already be operated in another -/// thread), and must be suitable for simultaneous initialisation across multiple -/// threads. +/// should also be implemented, which should not operate in another thread & +/// must be sendable between isolates (because it will already be operated in +/// another thread), and must be suitable for simultaneous initialisation across +/// multiple threads. /// /// Should be set in [FMTCBackendAccess] when ready to use, and unset when not. /// See documentation on that class for more information. @@ -144,9 +144,11 @@ abstract interface class FMTCBackendInternal }); /// {@template fmtc.backend.getStoreStats} - /// Retrieve the following statistics about the specified store (all available): + /// Retrieve the following statistics about the specified store (all + /// available): /// - /// * `size`: total number of KiBs of all tiles' bytes (not 'real total' size) + /// * `size`: total number of KiBs of all tiles' bytes (not 'real total' + /// size) /// * `length`: number of tiles belonging /// * `hits`: number of successful tile retrievals when browsing /// * `misses`: number of unsuccessful tile retrievals when browsing @@ -279,8 +281,9 @@ abstract interface class FMTCBackendInternal /// > [!WARNING] /// > Any existing value for the specified key will be overwritten. /// - /// Prefer using [setBulkMetadata] when setting multiple keys. Only one backend - /// operation is required to set them all at once, and so is more efficient. + /// Prefer using [setBulkMetadata] when setting multiple keys. Only one + /// backend operation is required to set them all at once, and so is more + /// efficient. /// {@endtemplate} Future setMetadata({ required String storeName, diff --git a/lib/src/backend/interfaces/backend/internal_thread_safe.dart b/lib/src/backend/interfaces/backend/internal_thread_safe.dart index fa34acb9..5f6756a0 100644 --- a/lib/src/backend/interfaces/backend/internal_thread_safe.dart +++ b/lib/src/backend/interfaces/backend/internal_thread_safe.dart @@ -1,6 +1,9 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE +// +// ignore_for_file: avoid_futureor_void + import 'dart:async'; import 'dart:typed_data'; diff --git a/lib/src/backend/interfaces/models.dart b/lib/src/backend/interfaces/models.dart index 73a04416..a738dc40 100644 --- a/lib/src/backend/interfaces/models.dart +++ b/lib/src/backend/interfaces/models.dart @@ -1,6 +1,9 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE +// TODO: Maybe bad design - do we really want inheritance? +// ignore_for_file: avoid_equals_and_hash_code_on_mutable_classes + import 'dart:typed_data'; import 'package:meta/meta.dart'; diff --git a/lib/src/bulk_download/external/download_progress.dart b/lib/src/bulk_download/external/download_progress.dart index f4e684d7..853efbdd 100644 --- a/lib/src/bulk_download/external/download_progress.dart +++ b/lib/src/bulk_download/external/download_progress.dart @@ -286,8 +286,8 @@ class DownloadProgress { /// /// The difference between [DownloadableRegion.end] and /// [DownloadableRegion.start]. If there is no endpoint set, this is the - /// the maximum number of tiles actually available in the region, as determined - /// by [StoreDownload.countTiles]. + /// the maximum number of tiles actually available in the region, as + /// determined by [StoreDownload.countTiles]. final int maxTilesCount; /// The current elapsed duration of the download diff --git a/lib/src/bulk_download/internal/instance.dart b/lib/src/bulk_download/internal/instance.dart index ba429079..e6bcef87 100644 --- a/lib/src/bulk_download/internal/instance.dart +++ b/lib/src/bulk_download/internal/instance.dart @@ -22,11 +22,4 @@ class DownloadInstance { Future Function()? requestCancel; Future Function()? requestPause; void Function()? requestResume; - - @override - bool operator ==(Object other) => - identical(this, other) || (other is DownloadInstance && id == other.id); - - @override - int get hashCode => id.hashCode; } diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index 52fe9b00..c4e77404 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -59,6 +59,8 @@ Future _downloadManager( ), headers: headers, ); + // We don't care about the exact error + // ignore: avoid_catches_without_on_clauses } catch (_) { seaTileBytes = null; } @@ -188,6 +190,7 @@ Future _downloadManager( case _DownloadManagerControlCmd.cancel: try { cancelSignal.complete(); + // If the signal is already complete, that's fine // ignore: avoid_catching_errors, empty_catches } on StateError {} case _DownloadManagerControlCmd.pause: @@ -413,6 +416,7 @@ Future _downloadManager( onDone: () { try { cancelSignal.complete(); + // If the signal is already complete, that's fine // ignore: avoid_catching_errors, empty_catches } on StateError {} diff --git a/lib/src/bulk_download/internal/rate_limited_stream.dart b/lib/src/bulk_download/internal/rate_limited_stream.dart index 02665f19..a546c735 100644 --- a/lib/src/bulk_download/internal/rate_limited_stream.dart +++ b/lib/src/bulk_download/internal/rate_limited_stream.dart @@ -5,14 +5,15 @@ import 'dart:async'; /// Rate limiting extension, see [rateLimit] for more information extension RateLimitedStream on Stream { - /// Transforms a series of events to an output stream where a delay of at least - /// [minimumSpacing] is inserted between every event + /// Transforms a series of events to an output stream where a delay of at + /// least [minimumSpacing] is inserted between every event /// /// The input stream may close before the output stream. /// /// Illustration of the output stream, where one decimal is 500ms, and /// [minimumSpacing] is set to 1s: - /// ``` + /// + /// ```txt /// Input: .ABC....DE..F........GH /// Output: .A..B..C..D..E..F....G..H /// ``` diff --git a/lib/src/bulk_download/internal/thread.dart b/lib/src/bulk_download/internal/thread.dart index af25a44c..626bd061 100644 --- a/lib/src/bulk_download/internal/thread.dart +++ b/lib/src/bulk_download/internal/thread.dart @@ -100,6 +100,8 @@ Future _singleDownloadThread( try { response = await httpClient.get(Uri.parse(networkUrl), headers: input.headers); + // We don't care about the exact error + // ignore: avoid_catches_without_on_clauses } catch (err) { send( FailedRequestTileEvent._( diff --git a/lib/src/bulk_download/internal/tile_loops/count.dart b/lib/src/bulk_download/internal/tile_loops/count.dart index 77a71a04..be2075fe 100644 --- a/lib/src/bulk_download/internal/tile_loops/count.dart +++ b/lib/src/bulk_download/internal/tile_loops/count.dart @@ -26,8 +26,8 @@ class TileCounters { min(region.end ?? largestInt, tileCount) - min(region.start - 1, tileCount); - /// Returns the number of tiles within a [DownloadableRegion] with generic type - /// [RectangleRegion] + /// Returns the number of tiles within a [DownloadableRegion] with generic + /// type [RectangleRegion] @internal static int rectangleTiles(DownloadableRegion region) { final northWest = region.originalRegion.bounds.northWest; @@ -52,8 +52,8 @@ class TileCounters { return _trimToRange(region, tileCount); } - /// Returns the number of tiles within a [DownloadableRegion] with generic type - /// [CircleRegion] + /// Returns the number of tiles within a [DownloadableRegion] with generic + /// type [CircleRegion] @internal static int circleTiles(DownloadableRegion region) { int tileCount = 0; @@ -98,8 +98,8 @@ class TileCounters { return _trimToRange(region, tileCount); } - /// Returns the number of tiles within a [DownloadableRegion] with generic type - /// [LineRegion] + /// Returns the number of tiles within a [DownloadableRegion] with generic + /// type [LineRegion] @internal static int lineTiles(DownloadableRegion region) { // Overlap algorithm originally in Python, available at @@ -238,8 +238,8 @@ class TileCounters { return _trimToRange(region, tileCount); } - /// Returns the number of tiles within a [DownloadableRegion] with generic type - /// [CustomPolygonRegion] + /// Returns the number of tiles within a [DownloadableRegion] with generic + /// type [CustomPolygonRegion] @internal static int customPolygonTiles( DownloadableRegion region, @@ -304,8 +304,8 @@ class TileCounters { return _trimToRange(region, tileCount); } - /// Returns the number of tiles within a [DownloadableRegion] with generic type - /// [MultiRegion] + /// Returns the number of tiles within a [DownloadableRegion] with generic + /// type [MultiRegion] @internal static int multiTiles(DownloadableRegion region) => region.originalRegion.regions diff --git a/lib/src/bulk_download/internal/tile_loops/generate.dart b/lib/src/bulk_download/internal/tile_loops/generate.dart index 965e75c0..459042ab 100644 --- a/lib/src/bulk_download/internal/tile_loops/generate.dart +++ b/lib/src/bulk_download/internal/tile_loops/generate.dart @@ -3,13 +3,13 @@ part of 'shared.dart'; -/// A set of methods for each type of [BaseRegion] that generates the coordinates -/// of every tile within the specified [DownloadableRegion] +/// A set of methods for each type of [BaseRegion] that generates the +/// coordinates of every tile within the specified [DownloadableRegion] /// /// These methods must be run within seperate isolates, as they do heavy, /// potentially lengthy computation. They do perform multiple-communication, -/// sending a new coordinate after they recieve a request message only. They will -/// kill themselves after there are no tiles left to generate. +/// sending a new coordinate after they recieve a request message only. They +/// will kill themselves after there are no tiles left to generate. /// /// See [TileCounters] for methods that do not generate each coordinate, but /// just count the number of tiles with a more efficient method. @@ -221,12 +221,19 @@ class TileGenerators { ({SendPort sendPort, DownloadableRegion region}) input, { StreamQueue? multiRequestQueue, }) async { - // This took some time and is fairly complicated, so this is the overall explanation: - // 1. Given 4 `LatLng` points, create a 'straight' rectangle around the 'rotated' rectangle, that can be defined with just 2 `LatLng` points - // 2. Convert the straight rectangle into tile numbers, and loop through the same as `rectangleTiles` - // 3. For every generated tile number (which represents top-left of the tile), generate the rest of the tile corners - // 4. Check whether the square tile overlaps the rotated rectangle from the start, add it to the list if it does - // 5. Keep track of the number of overlaps per row: if there was one overlap and now there isn't, skip the rest of the row because we can be sure there are no more tiles + // This took some time and is fairly complicated, so this is the overall + // explanation: + // 1. Given 4 `LatLng` points, create a 'straight' rectangle around the + // 'rotated' rectangle, that can be defined with just 2 `LatLng` points + // 2. Convert the straight rectangle into tile numbers, and loop through the + // same as `rectangleTiles` + // 3. For every generated tile number (which represents top-left of the + // tile), generate the rest of the tile corners + // 4. Check whether the square tile overlaps the rotated rectangle from the + // start, add it to the list if it does + // 5. Keep track of the number of overlaps per row: if there was one overlap + // and now there isn't, skip the rest of the row because we can be sure + // there are no more tiles // Overlap algorithm originally in Python, available at https://stackoverflow.com/a/56962827/11846040 bool overlap(_Polygon a, _Polygon b) { diff --git a/lib/src/bulk_download/internal/tile_loops/shared.dart b/lib/src/bulk_download/internal/tile_loops/shared.dart index 0aecdd92..d2f3004d 100644 --- a/lib/src/bulk_download/internal/tile_loops/shared.dart +++ b/lib/src/bulk_download/internal/tile_loops/shared.dart @@ -18,6 +18,7 @@ import '../../../misc/int_extremes.dart'; part 'count.dart'; part 'generate.dart'; +@immutable class _Polygon { _Polygon(Point nw, Point ne, Point se, Point sw) : points = [nw, ne, se, sw] { diff --git a/lib/src/providers/image_provider/browsing_errors.dart b/lib/src/providers/image_provider/browsing_errors.dart index 042ab465..dd29109e 100644 --- a/lib/src/providers/image_provider/browsing_errors.dart +++ b/lib/src/providers/image_provider/browsing_errors.dart @@ -125,16 +125,16 @@ enum FMTCBrowsingErrorType { ), /// Failed to load the tile from the cache or the network because it was - /// missing from the cache and the server responded with a HTTP code other than - /// 200 OK + /// missing from the cache and the server responded with a HTTP code other + /// than 200 OK /// /// Check that the [TileLayer.urlTemplate] is correct, that any necessary /// authorization data is correctly included, and that the server serves the /// viewed region. negativeFetchResponse( 'Failed to load the tile from the cache or the network because it was ' - 'missing from the cache and the server responded with a HTTP code other ' - 'than 200 OK.', + 'missing from the cache and the server responded with a HTTP code ' + 'other than 200 OK.', 'Check that the `TileLayer.urlTemplate` is correct, that any necessary ' 'authorization data is correctly included, and that the server serves ' 'the viewed region.', diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index bb2d350a..e153f03c 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -5,10 +5,11 @@ part of '../../../flutter_map_tile_caching.dart'; /// A specialised [ImageProvider] that uses FMTC internals to enable browse /// caching +@immutable class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { /// Create a specialised [ImageProvider] that uses FMTC internals to enable /// browse caching - _FMTCImageProvider({ + const _FMTCImageProvider({ required this.provider, required this.options, required this.coords, @@ -27,8 +28,8 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { /// Function invoked when the image starts loading (not from cache) /// - /// Used with [finishedLoadingBytes] to safely dispose of the `httpClient` only - /// after all tiles have loaded. + /// Used with [finishedLoadingBytes] to safely dispose of the `httpClient` + /// only after all tiles have loaded. final void Function() startedLoading; /// Function invoked when the image completes loading bytes from the network @@ -109,8 +110,9 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { cacheFetchDuration: currentTLIR.cacheFetchDuration, networkFetchDuration: currentTLIR.networkFetchDuration, ) + // `Map` is mutable, so must notify manually // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - ..notifyListeners(); // `Map` is mutable, so must notify manually + ..notifyListeners(); } } @@ -144,19 +146,12 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { Future<_FMTCImageProvider> obtainKey(ImageConfiguration configuration) => SynchronousFuture(this); + // TODO: Incorporate tile provider & tile layer options @override bool operator ==(Object other) => identical(this, other) || - (other is _FMTCImageProvider && - other.coords == - coords /*&& - other.provider == provider && - other.options == options*/ - ); + (other is _FMTCImageProvider && other.coords == coords); @override - int get hashCode => coords - .hashCode; /*Object.hash( - coords, /*, provider, options*/ - );*/ + int get hashCode => coords.hashCode; } diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_get_bytes.dart index 2f096ddb..65486c96 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_get_bytes.dart @@ -177,6 +177,8 @@ Future _internalGetBytes({ 0 ? null : Exception('Image was decodable, but had a width of 0'); + // We don't care about the exact error + // ignore: avoid_catches_without_on_clauses } catch (e) { isValidImageData = e; } @@ -230,7 +232,7 @@ Future _internalGetBytes({ // Cache tile to necessary stores if (writeTileToIntermediate.isNotEmpty || provider.otherStoresStrategy == BrowseStoreStrategy.readUpdateCreate) { - currentTLIR?.storesWriteResult = FMTCBackendAccess.internal.writeTile( + final writeOp = FMTCBackendAccess.internal.writeTile( storeNames: writeTileToIntermediate, writeAllNotIn: provider.otherStoresStrategy == BrowseStoreStrategy.readUpdateCreate @@ -238,8 +240,10 @@ Future _internalGetBytes({ : null, url: matcherUrl, bytes: response.bodyBytes, - // ignore: unawaited_futures - )..then((result) { + ); + currentTLIR?.storesWriteResult = writeOp; + unawaited( + writeOp.then((result) { final createdIn = result.entries.where((e) => e.value).map((e) => e.key); @@ -250,7 +254,8 @@ Future _internalGetBytes({ FMTCBackendAccess.internal.removeOldestTilesAboveLimit( storeNames: createdIn.toList(growable: false), ); - }); + }), + ); } currentTLIR?.resultPath = TileLoadingInterceptorResultPath.fetchedFromNetwork; diff --git a/lib/src/providers/tile_loading_interceptor/result.dart b/lib/src/providers/tile_loading_interceptor/result.dart index 78d42be0..bc17fccc 100644 --- a/lib/src/providers/tile_loading_interceptor/result.dart +++ b/lib/src/providers/tile_loading_interceptor/result.dart @@ -57,8 +57,8 @@ class TileLoadingInterceptorResult { /// error/exception thrown whilst loading the tile - which is likely to be an /// [FMTCBrowsingError]. /// - /// See [didComplete] for a boolean result. If `null`, see [resultPath] for the - /// exact result path. + /// See [didComplete] for a boolean result. If `null`, see [resultPath] for + /// the exact result path. final ({Object error, StackTrace stackTrace})? error; /// Indicates whether the tile completed loading successfully @@ -86,11 +86,12 @@ class TileLoadingInterceptorResult { /// /// Calculated with: /// - /// ``` + /// ```txt /// `useOtherStoresAsFallbackOnly` && /// `resultPath` == TileLoadingInterceptorResultPath.cacheAsFallback && /// && - /// + /// /// ``` final bool tileRetrievedFromOtherStoresAsFallback; @@ -98,7 +99,7 @@ class TileLoadingInterceptorResult { /// /// Calculated with: /// - /// ``` + /// ```txt /// && /// ( /// `loadingStrategy` == BrowseLoadingStrategy.onlineFirst || diff --git a/lib/src/providers/tile_provider/strategies.dart b/lib/src/providers/tile_provider/strategies.dart index c7ad21de..6b7b30c2 100644 --- a/lib/src/providers/tile_provider/strategies.dart +++ b/lib/src/providers/tile_provider/strategies.dart @@ -10,12 +10,12 @@ typedef CacheBehavior = BrowseLoadingStrategy; /// Determines whether the network or cache is preferred during browse caching, /// and how to fallback /// -/// | `BrowseLoadingStrategy` | Preferred method | Fallback method | -/// |--------------------------|------------------------|-----------------------| -/// | `cacheOnly` | Cache | None | -/// | `cacheFirst` | Cache | Network | -/// | `onlineFirst` | Network | Cache | -/// | *Standard Tile Provider* | *Network* | *None* | +/// | `BrowseLoadingStrategy` | Preferred method | Fallback method | +/// |--------------------------|------------------------|----------------------| +/// | `cacheOnly` | Cache | None | +/// | `cacheFirst` | Cache | Network | +/// | `onlineFirst` | Network | Cache | +/// | *Standard Tile Provider* | *Network* | *None* | enum BrowseLoadingStrategy { /// Only fetch tiles from the local cache /// diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index afd1ac0b..dbf70e3c 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -3,8 +3,8 @@ part of '../../../flutter_map_tile_caching.dart'; -/// Specialised [TileProvider] that uses a specialised [ImageProvider] to connect -/// to FMTC internals and enable advanced caching/retrieval logic +/// Specialised [TileProvider] that uses a specialised [ImageProvider] to +/// connect to FMTC internals and enable advanced caching/retrieval logic /// /// To use a single store, use [FMTCStore.getTileProvider]. /// @@ -15,12 +15,13 @@ part of '../../../flutter_map_tile_caching.dart'; /// To use all stores, use the [FMTCTileProvider.allStores] constructor. See /// documentation on [otherStoresStrategy] for information on usage. /// -/// An "FMTC" identifying mark is injected into the "User-Agent" header generated -/// by flutter_map, except if specified in the constructor. For technical -/// details, see [_CustomUserAgentCompatMap]. +/// An "FMTC" identifying mark is injected into the "User-Agent" header +/// generated by flutter_map, except if specified in the constructor. For +/// technical details, see [_CustomUserAgentCompatMap]. /// /// Can be constructed alternatively with [FMTCStore.getTileProvider] to /// support a single store. +@immutable class FMTCTileProvider extends TileProvider { /// See [FMTCTileProvider] for information FMTCTileProvider.multipleStores({ @@ -88,8 +89,8 @@ class FMTCTileProvider extends TileProvider { /// internal tile cache lookups will have less constraints. /// /// Also see [useOtherStoresAsFallbackOnly] for whether these unspecified - /// stores should only be used as a last resort or in addition to the specified - /// stores as normal. + /// stores should only be used as a last resort or in addition to the + /// specified stores as normal. /// /// Stores specified in [storeNames] but associated with a `null` value will /// not not gain this behaviour. @@ -105,8 +106,8 @@ class FMTCTileProvider extends TileProvider { /// [FMTCTileProvider.otherStoresStrategy] after all specified stores have /// been exhausted (where the tile was not present) /// - /// When tiles are retrieved from other stores, it is counted as a miss for the - /// specified store(s). + /// When tiles are retrieved from other stores, it is counted as a miss for + /// the specified store(s). /// /// Note that an attempt is *always* made to read the tile from the cache, /// regardless of whether the tile is then actually retrieved from the cache @@ -144,14 +145,15 @@ class FMTCTileProvider extends TileProvider { /// Method used to create a tile's storage-suitable UID from it's real URL /// /// The input string is the tile's URL. The output string should be a unique - /// string to that tile that will remain as stable as necessary if parts of the - /// URL not directly related to the tile image change. + /// string to that tile that will remain as stable as necessary if parts of + /// the URL not directly related to the tile image change. /// /// For more information, see: /// . /// /// [urlTransformerOmitKeyValues] may be used as a transformer to omit entire - /// key-value pairs from a URL where the key matches one of the specified keys. + /// key-value pairs from a URL where the key matches one of the specified + /// keys. /// /// > [!IMPORTANT] /// > The callback will be passed to a different isolate: therefore, avoid @@ -165,7 +167,8 @@ class FMTCTileProvider extends TileProvider { /// storage-suitable UID is the tile's real URL. final UrlTransformer urlTransformer; - /// A custom callback that will be called when an [FMTCBrowsingError] is thrown + /// A custom callback that will be called when an [FMTCBrowsingError] is + /// thrown /// /// If no value is returned, the error will be (re)thrown as normal. However, /// if a [Uint8List], that will be displayed instead (decoded as an image), @@ -275,9 +278,9 @@ class FMTCTileProvider extends TileProvider { /// only Flutter decodable data is being used (ie. most raster tiles) (and is /// set `true` when used by `loadImage` internally). This provides an extra /// layer of protection by preventing invalid data from being stored inside - /// the cache, which could cause further issues at a later point. However, this - /// may be set `false` intentionally, for example to allow for vector tiles - /// to be stored. If this is `true`, and the image is invalid, an + /// the cache, which could cause further issues at a later point. However, + /// this may be set `false` intentionally, for example to allow for vector + /// tiles to be stored. If this is `true`, and the image is invalid, an /// [FMTCBrowsingError] with sub-category /// [FMTCBrowsingErrorType.invalidImageData] will be thrown - if `false`, then /// FMTC will not throw an error, but Flutter will if the bytes are attempted diff --git a/lib/src/regions/downloadable_region.dart b/lib/src/regions/downloadable_region.dart index 798f7838..85c80052 100644 --- a/lib/src/regions/downloadable_region.dart +++ b/lib/src/regions/downloadable_region.dart @@ -6,6 +6,7 @@ part of '../../flutter_map_tile_caching.dart'; /// A downloadable region to be passed to bulk download functions /// /// Construct via [BaseRegion.toDownloadable]. +@immutable class DownloadableRegion { DownloadableRegion._( this.originalRegion, { diff --git a/lib/src/regions/recovered_region.dart b/lib/src/regions/recovered_region.dart index 697600ee..dd62bba7 100644 --- a/lib/src/regions/recovered_region.dart +++ b/lib/src/regions/recovered_region.dart @@ -9,11 +9,12 @@ part of '../../flutter_map_tile_caching.dart'; /// Only [id] is used to compare equality. /// /// See [RootRecovery] for information about the recovery system. +@immutable class RecoveredRegion { /// Create a wrapper containing recovery information around a /// [DownloadableRegion] @internal - RecoveredRegion({ + const RecoveredRegion({ required this.id, required this.storeName, required this.time, diff --git a/lib/src/regions/shapes/multi.dart b/lib/src/regions/shapes/multi.dart index 2f2c143b..6e475091 100644 --- a/lib/src/regions/shapes/multi.dart +++ b/lib/src/regions/shapes/multi.dart @@ -17,7 +17,8 @@ part of '../../../../flutter_map_tile_caching.dart'; /// Overlaps and intersections are not (yet) compiled into single /// [CustomPolygonRegion]s. Therefore, where regions are known to overlap: /// -/// * (particularly where regions are [RectangleRegion]s & [CustomPolygonRegion]s) +/// * (particularly where regions are [RectangleRegion]s & +/// [CustomPolygonRegion]s) /// Use ['package:polybool'](https://pub.dev/packages/polybool) (a 3rd party /// package in no way associated with FMTC) to take the `union` all polygons: /// this will remove self-intersections, combine overlapping polygons into diff --git a/lib/src/root/recovery.dart b/lib/src/root/recovery.dart index dccf9636..17622bd2 100644 --- a/lib/src/root/recovery.dart +++ b/lib/src/root/recovery.dart @@ -1,8 +1,6 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -// ignore_for_file: use_late_for_private_fields_and_variables - part of '../../flutter_map_tile_caching.dart'; /// Manages the download recovery of all sub-stores of this [FMTCRoot] @@ -37,15 +35,13 @@ part of '../../flutter_map_tile_caching.dart'; /// > Options set at download time, in [StoreDownload.startForeground], are not /// > included. class RootRecovery { - // ignore: prefer_const_constructors - factory RootRecovery._() => _instance ??= RootRecovery._uninstanced({}); - const RootRecovery._uninstanced(Set downloadsOngoing) - : _downloadsOngoing = downloadsOngoing; + factory RootRecovery._() => _instance ??= RootRecovery._uninstanced(); + RootRecovery._uninstanced(); static RootRecovery? _instance; /// Determines which downloads are known to be on-going, and therefore /// can be ignored when fetching [recoverableRegions] - final Set _downloadsOngoing; + final Set _downloadsOngoing = {}; /// {@macro fmtc.backend.watchRecovery} Stream watch({ diff --git a/lib/src/root/root.dart b/lib/src/root/root.dart index f5fa84d0..fa89b007 100644 --- a/lib/src/root/root.dart +++ b/lib/src/root/root.dart @@ -6,8 +6,8 @@ part of '../../flutter_map_tile_caching.dart'; /// Provides access to statistics, recovery, migration (and the import /// functionality) on the intitialised root. /// -/// Management services are not provided here, instead use methods on the backend -/// directly. +/// Management services are not provided here, instead use methods on the +/// backend directly. /// /// Note that this does not provide direct access to any [FMTCStore]s. abstract class FMTCRoot { diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 87e01564..ad74338e 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -88,7 +88,8 @@ class StoreDownload { /// > [!WARNING] /// > Using buffering will mean that an unexpected forceful quit (such as an /// > app closure, [cancel] is safe) will result in losing the tiles that are - /// > currently in the buffer. It will also increase the memory (RAM) required. + /// > currently in the buffer. It will also increase the memory (RAM) + /// > required. /// /// > [!WARNING] /// > Skipping sea tiles will not reduce the number of downloads - tiles must @@ -98,14 +99,14 @@ class StoreDownload { /// --- /// /// Although disabled `null` by default, [rateLimit] can be used to impose a - /// limit on the maximum number of tiles that can be attempted per second. This - /// is useful to avoid placing too much strain on tile servers and avoid - /// external rate limiting. Note that the [rateLimit] is only approximate. Also - /// note that all tile attempts are rate limited, even ones that do not need a - /// server request. - /// - /// To check whether the current [DownloadProgress.tilesPerSecond] statistic is - /// currently limited by [rateLimit], check + /// limit on the maximum number of tiles that can be attempted per second. + /// This is useful to avoid placing too much strain on tile servers and avoid + /// external rate limiting. Note that the [rateLimit] is only approximate. + /// Also note that all tile attempts are rate limited, even ones that do not + /// need a server request. + /// + /// To check whether the current [DownloadProgress.tilesPerSecond] statistic + /// is currently limited by [rateLimit], check /// [DownloadProgress.isTPSArtificiallyCapped]. /// /// --- diff --git a/lib/src/store/statistics.dart b/lib/src/store/statistics.dart index d6492a0f..67f01e02 100644 --- a/lib/src/store/statistics.dart +++ b/lib/src/store/statistics.dart @@ -1,8 +1,6 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -// ignore_for_file: use_late_for_private_fields_and_variables - part of '../../flutter_map_tile_caching.dart'; /// Provides statistics about an [FMTCStore] @@ -17,9 +15,9 @@ class StoreStats { /// {@macro fmtc.backend.getStoreStats} /// /// {@template fmtc.frontend.storestats.efficiency} - /// Prefer using [all] when multiple statistics are required instead of getting - /// them individually. Only one backend operation is required to get all the - /// stats, and so is more efficient. + /// Prefer using [all] when multiple statistics are required instead of + /// getting them individually. Only one backend operation is required to get + /// all the stats, and so is more efficient. /// {@endtemplate} Future<({double size, int length, int hits, int misses})> get all => FMTCBackendAccess.internal.getStoreStats(storeName: _storeName); diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index d8b62ebe..1ab6c5c4 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -1,8 +1,6 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -// ignore_for_file: use_late_for_private_fields_and_variables - part of '../../flutter_map_tile_caching.dart'; /// {@template fmtc.fmtcStore} @@ -14,6 +12,7 @@ part of '../../flutter_map_tile_caching.dart'; /// > Constructing an instance of this class will not automatically create it. /// > To create this store, use [manage] > [StoreManagement.create]. /// {@endtemplate} +@immutable class FMTCStore { /// {@macro fmtc.fmtcStore} const FMTCStore(this.storeName); diff --git a/test/region_tile_test.dart b/test/region_tile_test.dart index ec62002f..14db6da5 100644 --- a/test/region_tile_test.dart +++ b/test/region_tile_test.dart @@ -1,6 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE +// Printing out is part of the tests and easy without logging packages // ignore_for_file: avoid_print import 'dart:isolate'; diff --git a/tile_server/bin/tile_server.dart b/tile_server/bin/tile_server.dart index 9e2beb94..35e856ba 100644 --- a/tile_server/bin/tile_server.dart +++ b/tile_server/bin/tile_server.dart @@ -22,7 +22,8 @@ Future main(List _) async { ..setTextStyle() ..write('© Luka S (JaffaKetchup)\n') ..write( - "Miniature fake tile server designed to test FMTC's throughput and download speeds\n\n", + "Miniature fake tile server designed to test FMTC's throughput and " + 'download speeds\n\n', ); // Monitor requests per second measurement (tps) @@ -47,7 +48,9 @@ Future main(List _) async { final requestTime = ctx.at; requestTimestamps.add(requestTime); console.write( - '[$requestTime] ${ctx.method} ${ctx.path}: ${ctx.response.statusCode}\t\t$servedSeaTiles sea tiles this session\t\t\t$lastRate tps - ${currentArtificialDelay.inMilliseconds} ms delay\n', + '[$requestTime] ${ctx.method} ${ctx.path}: ${ctx.response.statusCode}\t' + '\t$servedSeaTiles sea tiles this session\t\t\t$lastRate tps - ' + '${currentArtificialDelay.inMilliseconds} ms delay\n', ); }, port: 7070, @@ -143,7 +146,8 @@ Future main(List _) async { ..write('Now serving tiles at 127.0.0.1:7070/{z}/{x}/{y}\n\n') ..write("Press 'q' to kill server\n") ..write( - 'Press UP or DOWN to manipulate artificial delay by ${artificialDelayChangeAmount.inMilliseconds} ms\n\n', + 'Press UP or DOWN to manipulate artificial delay by ' + '${artificialDelayChangeAmount.inMilliseconds} ms\n\n', ) ..setTextStyle() ..write('----------\n'); From 491924e0f2a0c6b4e9acfe17ba1d465cb4de3d42 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 30 Dec 2024 21:19:58 +0000 Subject: [PATCH 82/97] Renaming of some identifiers Documentation improvements Added checks to ensure store exists before attempting to write to it Improved key correctness for image provider Deprecated `FMTCStore.getTileProvider` --- .../src/screens/main/map_view/map_view.dart | 6 +- lib/flutter_map_tile_caching.dart | 2 +- .../backend/internal_workers/shared.dart | 14 +- lib/src/backend/interfaces/models.dart | 20 -- .../image_provider/browsing_errors.dart | 6 - .../image_provider/image_provider.dart | 23 ++- ..._bytes.dart => internal_tile_browser.dart} | 12 +- .../tile_loading_interceptor/result.dart | 6 +- .../custom_user_agent_compat_map.dart | 2 +- .../providers/tile_provider/strategies.dart | 9 +- .../tile_provider/tile_provider.dart | 193 ++++++++++++------ lib/src/regions/base_region.dart | 28 ++- lib/src/root/recovery.dart | 12 +- lib/src/root/statistics.dart | 7 +- lib/src/store/download.dart | 50 +++-- lib/src/store/statistics.dart | 10 +- lib/src/store/store.dart | 18 +- 17 files changed, 239 insertions(+), 179 deletions(-) rename lib/src/providers/image_provider/{internal_get_bytes.dart => internal_tile_browser.dart} (96%) 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 ec53dc6b..bce63ed8 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -16,6 +16,7 @@ import '../../../shared/misc/store_metadata_keys.dart'; import '../../../shared/state/download_provider.dart'; import '../../../shared/state/general_provider.dart'; import '../../../shared/state/region_selection_provider.dart'; +import '../../../shared/state/selected_tab_state.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'; @@ -312,8 +313,8 @@ class _MapViewState extends State with TickerProviderStateMixin { maxNativeZoom: 20, tileProvider: widget.mode != MapViewMode.standard ? NetworkTileProvider() - : FMTCTileProvider.multipleStores( - storeNames: compiledStoreNames, + : FMTCTileProvider( + stores: compiledStoreNames, otherStoresStrategy: otherStoresStrategy, loadingStrategy: provider.loadingStrategy, useOtherStoresAsFallbackOnly: @@ -340,6 +341,7 @@ class _MapViewState extends State with TickerProviderStateMixin { context.select((p) => p.isFocused); final map = FlutterMap( + key: ValueKey(selectedTabState.value), mapController: _mapController.mapController, options: mapOptions, children: [ diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 1111774d..d90df1d6 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -47,7 +47,7 @@ part 'src/providers/tile_loading_interceptor/result.dart'; part 'src/providers/tile_loading_interceptor/map_typedef.dart'; part 'src/providers/tile_loading_interceptor/result_path.dart'; part 'src/providers/image_provider/image_provider.dart'; -part 'src/providers/image_provider/internal_get_bytes.dart'; +part 'src/providers/image_provider/internal_tile_browser.dart'; part 'src/providers/tile_provider/custom_user_agent_compat_map.dart'; part 'src/providers/tile_provider/strategies.dart'; part 'src/providers/tile_provider/tile_provider.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart index 9b2911c4..053b05e0 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart @@ -14,13 +14,21 @@ Map _sharedWriteSingleTile({ final storesBox = root.box(); final rootBox = root.box(); + final availableStoreNames = storesBox.getAll().map((e) => e.name); + + for (final storeName in storeNames) { + if (!availableStoreNames.contains(storeName)) { + throw StoreNotExists(storeName: storeName); + } + } + final compiledStoreNames = writeAllNotIn == null ? storeNames : [ ...storeNames, - ...storesBox.getAll().map((e) => e.name).where( - (e) => !writeAllNotIn.contains(e) && !storeNames.contains(e), - ), + ...availableStoreNames.whereNot( + (e) => writeAllNotIn.contains(e) || storeNames.contains(e), + ), ]; final tilesQuery = tiles.query(ObjectBoxTile_.url.equals(url)).build(); diff --git a/lib/src/backend/interfaces/models.dart b/lib/src/backend/interfaces/models.dart index a738dc40..b0ab75bf 100644 --- a/lib/src/backend/interfaces/models.dart +++ b/lib/src/backend/interfaces/models.dart @@ -1,13 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -// TODO: Maybe bad design - do we really want inheritance? -// ignore_for_file: avoid_equals_and_hash_code_on_mutable_classes - import 'dart:typed_data'; - -import 'package:meta/meta.dart'; - import '../../../flutter_map_tile_caching.dart'; /// Represents a tile (which is never directly exposed to the user) @@ -28,18 +22,4 @@ abstract base class BackendTile { /// The raw bytes of the image of this tile Uint8List get bytes; - - /// Uses [url] for equality comparisons only (unless the two objects are - /// [identical]) - /// - /// Overriding this in an implementation may cause FMTC logic to break, and is - /// therefore not recommended. - @override - @nonVirtual - bool operator ==(Object other) => - identical(this, other) || (other is BackendTile && url == other.url); - - @override - @nonVirtual - int get hashCode => url.hashCode; } diff --git a/lib/src/providers/image_provider/browsing_errors.dart b/lib/src/providers/image_provider/browsing_errors.dart index dd29109e..d91935b0 100644 --- a/lib/src/providers/image_provider/browsing_errors.dart +++ b/lib/src/providers/image_provider/browsing_errors.dart @@ -3,7 +3,6 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:http/http.dart'; -import 'package:http/io_client.dart'; import 'package:meta/meta.dart'; import '../../../flutter_map_tile_caching.dart'; @@ -109,11 +108,6 @@ enum FMTCBrowsingErrorType { /// Failed to load the tile from the cache or network because it was missing /// from the cache and there was an unexpected error when requesting from the /// server - /// - /// Try specifying a normal HTTP/1.1 [IOClient] when using - /// [FMTCStore.getTileProvider]. Check that the [TileLayer.urlTemplate] is - /// correct, that any necessary authorization data is correctly included, and - /// that the server serves the viewed region. unknownFetchException( 'Failed to load the tile from the cache or network because it was missing ' 'from the cache and there was an unexpected error when requesting from ' diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index e153f03c..bdad0f15 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -44,7 +44,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { ImageDecoderCallback decode, ) => MultiFrameImageStreamCompleter( - codec: getBytes( + codec: provideTile( coords: coords, options: options, provider: provider, @@ -59,19 +59,19 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { final tileUrl = provider.getTileUrl(coords, options); return [ - DiagnosticsProperty('Store names', provider.storeNames), + DiagnosticsProperty('Stores', provider.stores), DiagnosticsProperty('Tile coordinates', coords), DiagnosticsProperty('Tile URL', tileUrl), DiagnosticsProperty( 'Tile storage-suitable UID', - provider.urlTransformer(tileUrl), + provider.urlTransformer?.call(tileUrl) ?? tileUrl, ), ]; }, ); - /// {@macro fmtc.tileProvider.getBytes} - static Future getBytes({ + /// {@macro fmtc.tileProvider.provideTile} + static Future provideTile({ required TileCoordinates coords, required TileLayer options, required FMTCTileProvider provider, @@ -80,6 +80,8 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { void Function()? finishedLoadingBytes, bool requireValidImage = false, }) async { + startedLoading?.call(); + final currentTLIR = provider.tileLoadingInterceptor != null ? _TLIRConstructor._() : null; @@ -116,11 +118,9 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { } } - startedLoading?.call(); - final Uint8List bytes; try { - bytes = await _internalGetBytes( + bytes = await _internalTileBrowser( coords: coords, options: options, provider: provider, @@ -146,12 +146,13 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { Future<_FMTCImageProvider> obtainKey(ImageConfiguration configuration) => SynchronousFuture(this); - // TODO: Incorporate tile provider & tile layer options @override bool operator ==(Object other) => identical(this, other) || - (other is _FMTCImageProvider && other.coords == coords); + (other is _FMTCImageProvider && + other.coords == coords && + other.provider == provider); @override - int get hashCode => coords.hashCode; + int get hashCode => Object.hash(coords, provider); } diff --git a/lib/src/providers/image_provider/internal_get_bytes.dart b/lib/src/providers/image_provider/internal_tile_browser.dart similarity index 96% rename from lib/src/providers/image_provider/internal_get_bytes.dart rename to lib/src/providers/image_provider/internal_tile_browser.dart index 65486c96..db91667a 100644 --- a/lib/src/providers/image_provider/internal_get_bytes.dart +++ b/lib/src/providers/image_provider/internal_tile_browser.dart @@ -3,7 +3,7 @@ part of '../../../flutter_map_tile_caching.dart'; -Future _internalGetBytes({ +Future _internalTileBrowser({ required TileCoordinates coords, required TileLayer options, required FMTCTileProvider provider, @@ -29,7 +29,7 @@ Future _internalGetBytes({ } final networkUrl = provider.getTileUrl(coords, options); - final matcherUrl = provider.urlTransformer(networkUrl); + final matcherUrl = provider.urlTransformer?.call(networkUrl) ?? networkUrl; currentTLIR?.networkUrl = networkUrl; currentTLIR?.storageSuitableUID = matcherUrl; @@ -55,7 +55,7 @@ Future _internalGetBytes({ final tileRetrievableFromOtherStoresAsFallback = existingTile != null && provider.useOtherStoresAsFallbackOnly && - provider.storeNames.keys + provider.stores.keys .toSet() .intersection(allExistingStores.toSet()) .isEmpty; @@ -206,7 +206,7 @@ Future _internalGetBytes({ // their read/write settings // At this point, we've downloaded the tile anyway, so we might as well // write the stores that allow it, even if the existing tile hasn't expired - final writeTileToSpecified = provider.storeNames.entries + final writeTileToSpecified = provider.stores.entries .where( (e) => switch (e.value) { null => false, @@ -223,7 +223,7 @@ Future _internalGetBytes({ existingTile != null ? writeTileToSpecified.followedBy( intersectedExistingStores - .whereNot((e) => provider.storeNames.containsKey(e)), + .whereNot((e) => provider.stores.containsKey(e)), ) : writeTileToSpecified) .toSet() @@ -236,7 +236,7 @@ Future _internalGetBytes({ storeNames: writeTileToIntermediate, writeAllNotIn: provider.otherStoresStrategy == BrowseStoreStrategy.readUpdateCreate - ? provider.storeNames.keys.toList(growable: false) + ? provider.stores.keys.toList(growable: false) : null, url: matcherUrl, bytes: response.bodyBytes, diff --git a/lib/src/providers/tile_loading_interceptor/result.dart b/lib/src/providers/tile_loading_interceptor/result.dart index bc17fccc..a7b98836 100644 --- a/lib/src/providers/tile_loading_interceptor/result.dart +++ b/lib/src/providers/tile_loading_interceptor/result.dart @@ -3,8 +3,8 @@ part of '../../../flutter_map_tile_caching.dart'; -/// A 'temporary' object that collects information from [_internalGetBytes] to -/// be used to construct a [TileLoadingInterceptorResult] +/// A 'temporary' object that collects information from [_internalTileBrowser] +/// to be used to construct a [TileLoadingInterceptorResult] /// /// See documentation on [TileLoadingInterceptorResult] for more information class _TLIRConstructor { @@ -24,7 +24,7 @@ class _TLIRConstructor { } /// Information useful to debug and record detailed statistics for the loading -/// mechanisms and paths of a tile +/// mechanisms and paths of a browsed tile load @immutable class TileLoadingInterceptorResult { const TileLoadingInterceptorResult._({ diff --git a/lib/src/providers/tile_provider/custom_user_agent_compat_map.dart b/lib/src/providers/tile_provider/custom_user_agent_compat_map.dart index 492cf683..454d3a50 100644 --- a/lib/src/providers/tile_provider/custom_user_agent_compat_map.dart +++ b/lib/src/providers/tile_provider/custom_user_agent_compat_map.dart @@ -20,7 +20,7 @@ class _CustomUserAgentCompatMap extends MapView { /// /// The identifying mark is injected to seperate traffic sent via FMTC from /// standard flutter_map traffic, as it significantly changes the behaviour of - /// tile retrieval, and could generate more traffic. + /// tile retrieval. @override String putIfAbsent(String key, String Function() ifAbsent) { if (key != 'User-Agent') return super.putIfAbsent(key, ifAbsent); diff --git a/lib/src/providers/tile_provider/strategies.dart b/lib/src/providers/tile_provider/strategies.dart index 6b7b30c2..7dc2a538 100644 --- a/lib/src/providers/tile_provider/strategies.dart +++ b/lib/src/providers/tile_provider/strategies.dart @@ -4,7 +4,14 @@ part of '../../../flutter_map_tile_caching.dart'; /// Alias for [BrowseLoadingStrategy], to ease migration from v9 -> v10 -@Deprecated('`CacheBehavior` has been renamed to `BrowseLoadingStrategy`') +@Deprecated( + 'Rename all references to `BrowseLoadingStrategy` instead. ' + 'The new name is less ambiguous in the context of the new ' + '`BrowseStoreStrategy`, and does not depend on a British or American ' + 'spelling. ' + 'This feature was deprecated in v10, and will be removed in a future ' + 'version.', +) typedef CacheBehavior = BrowseLoadingStrategy; /// Determines whether the network or cache is preferred during browse caching, diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index dbf70e3c..cb41f603 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -6,64 +6,92 @@ part of '../../../flutter_map_tile_caching.dart'; /// Specialised [TileProvider] that uses a specialised [ImageProvider] to /// connect to FMTC internals and enable advanced caching/retrieval logic /// -/// To use a single store, use [FMTCStore.getTileProvider]. -/// -/// To use multiple stores, use the [FMTCTileProvider.multipleStores] -/// constructor. See documentation on [storeNames] and [otherStoresStrategy] +/// To use a single or multiple stores, use the [FMTCTileProvider.new] +/// constructor. See documentation on [stores] and [otherStoresStrategy] /// for information on usage. /// /// To use all stores, use the [FMTCTileProvider.allStores] constructor. See /// documentation on [otherStoresStrategy] for information on usage. /// -/// An "FMTC" identifying mark is injected into the "User-Agent" header -/// generated by flutter_map, except if specified in the constructor. For -/// technical details, see [_CustomUserAgentCompatMap]. -/// -/// Can be constructed alternatively with [FMTCStore.getTileProvider] to -/// support a single store. +/// {@template fmtc.fmtcTileProvider.constructionTip} +/// > [!TIP] +/// > +/// > **Minimize reconstructions of this provider by constructing it outside of +/// > the `build` method of a widget wherever possible.** +/// > +/// > If this is not possible, because one or more properties depend on +/// > inherited data (ie. via an `InheritedWidget`, `Provider`, etc.), define +/// > and construct as many properties as possible outside of the `build` +/// > method. +/// > +/// > * Manually constructing and initialising an [httpClient] once is much +/// > cheaper than the [FMTCTileProvider]'s constructors doing it automatically +/// > on every construction (every rebuild), and allows a single connection to +/// > the server to be maintained, massively improving tile loading speeds. Also +/// > see [httpClient]'s documentation. +/// > +/// > * Properties that use objects without a useful equality and hash code +/// > should always be defined once outside of the build method so that their +/// > identity (by [identical]) is not changed - for example, [httpClient], +/// > [tileLoadingInterceptor], [errorHandler], and [urlTransformer]. +/// > All properties comprise part of the [hashCode] & [operator ==], which are +/// > used to form the Flutter session [ImageCache] key in the internal image +/// > provider (alongside the tile coordinates). This key should not change for +/// > a tile unless the configuration is actually changed meaningfully, as this +/// > will disrupt the session cache, and mean tiles may need to be fetched +/// > unnecessarily. +/// > +/// > See the online documentation for an example of the recommended usage. +/// {@endtemplate} @immutable class FMTCTileProvider extends TileProvider { - /// See [FMTCTileProvider] for information - FMTCTileProvider.multipleStores({ - required this.storeNames, + /// Create an [FMTCTileProvider] that interacts with a subset of all available + /// stores + /// + /// See [stores] & [otherStoresStrategy] for information. + /// + /// {@macro fmtc.fmtcTileProvider.constructionTip} + FMTCTileProvider({ + required this.stores, this.otherStoresStrategy, this.loadingStrategy = BrowseLoadingStrategy.cacheFirst, this.useOtherStoresAsFallbackOnly = false, this.recordHitsAndMisses = true, this.cachedValidDuration = Duration.zero, - UrlTransformer? urlTransformer, + this.urlTransformer, this.errorHandler, this.tileLoadingInterceptor, Client? httpClient, @visibleForTesting this.fakeNetworkDisconnect = false, Map? headers, - }) : urlTransformer = (urlTransformer ?? (u) => u), + }) : _wasClientAutomaticallyGenerated = httpClient == null, httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), - _wasClientAutomaticallyGenerated = httpClient == null, super( headers: (headers?.containsKey('User-Agent') ?? false) ? headers : _CustomUserAgentCompatMap(headers ?? {}), ); - /// See [FMTCTileProvider] for information + /// Create an [FMTCTileProvider] that interacts with all available stores, + /// using one [BrowseStoreStrategy] efficiently + /// + /// {@macro fmtc.fmtcTileProvider.constructionTip} FMTCTileProvider.allStores({ required BrowseStoreStrategy allStoresStrategy, this.loadingStrategy = BrowseLoadingStrategy.cacheFirst, - this.useOtherStoresAsFallbackOnly = false, this.recordHitsAndMisses = true, this.cachedValidDuration = Duration.zero, - UrlTransformer? urlTransformer, + this.urlTransformer, this.errorHandler, this.tileLoadingInterceptor, Client? httpClient, @visibleForTesting this.fakeNetworkDisconnect = false, Map? headers, - }) : storeNames = const {}, + }) : stores = const {}, otherStoresStrategy = allStoresStrategy, - urlTransformer = (urlTransformer ?? (u) => u), - httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), + useOtherStoresAsFallbackOnly = false, _wasClientAutomaticallyGenerated = httpClient == null, + httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), super( headers: (headers?.containsKey('User-Agent') ?? false) ? headers @@ -77,11 +105,15 @@ class FMTCTileProvider extends TileProvider { /// /// Stores not included will not be used by default. However, /// [otherStoresStrategy] determines whether & how all other unspecified - /// stores should be used. Stores included but with a `null` value will be - /// exempt from [otherStoresStrategy]. - final Map storeNames; + /// stores should be used. Stores included in this mapping but with a `null` + /// value will be exempted from [otherStoresStrategy] (ie. unused). + /// + /// All specified store names should correspond to existing stores. + /// Non-existant stores may cause unexpected read behaviour and will throw a + /// [StoreNotExists] error if a tile is attempted to be written to it. + final Map stores; - /// The behaviour of all other stores not specified in [storeNames] + /// The behaviour of all other stores not specified in [stores] /// /// `null` means that all other stores will not be used. /// @@ -92,8 +124,8 @@ class FMTCTileProvider extends TileProvider { /// stores should only be used as a last resort or in addition to the /// specified stores as normal. /// - /// Stores specified in [storeNames] but associated with a `null` value will - /// not not gain this behaviour. + /// Stores specified in [stores] but associated with a `null` value will not + /// gain this behaviour. final BrowseStoreStrategy? otherStoresStrategy; /// Determines whether the network or cache is preferred during browse @@ -126,7 +158,7 @@ class FMTCTileProvider extends TileProvider { /// Whether to record the [StoreStats.hits] and [StoreStats.misses] statistics /// /// When enabled, hits will be recorded for all stores that the tile belonged - /// to and were present in [FMTCTileProvider.storeNames], when necessary. + /// to and were present in [FMTCTileProvider.stores], when necessary. /// Misses will be recorded for all stores specified in the tile provided, /// where necessary /// @@ -165,7 +197,7 @@ class FMTCTileProvider extends TileProvider { /// /// By default, the output string is the input string - that is, the /// storage-suitable UID is the tile's real URL. - final UrlTransformer urlTransformer; + final UrlTransformer? urlTransformer; /// A custom callback that will be called when an [FMTCBrowsingError] is /// thrown @@ -189,6 +221,7 @@ class FMTCTileProvider extends TileProvider { /// parameter: /// /// ```dart + /// // outside of the `build` method /// final tileLoadingInterceptor = /// ValueNotifier({}); // Do not use `const {}` /// ``` @@ -200,9 +233,32 @@ class FMTCTileProvider extends TileProvider { /// [Client] (such as a [IOClient]) used to make all network requests /// - /// If specified, then it will not be closed automatically on [dispose]al. - /// When closing manually, ensure no requests are currently underway, else - /// they will throw [ClientException]s. + /// If this provider could be rebuild frequently (ie. it is constructed in a + /// build method), a client should always be defined manually outside of the + /// build method and passed into the constructor. See the documentation tip on + /// [FMTCTileProvider] for more information. For example (this is also the + /// same client as created automatically by the constructor if no argument + /// is passed): + /// + /// ```dart + /// // `StatefulWidget` class definition + /// + /// class _...State extends State<...> { + /// late final _httpClient = IOClient(HttpClient()..userAgent = null); + /// // followed by other state contents, such as `build` + /// } + /// ``` + /// + /// Any specified user agent defined on the client will be overriden. + /// If a "User-Agent" header is specified in [headers] it will be used. + /// Otherwise, the default flutter_map user agent logic is used, followed by + /// an injected "FMTC" identifying mark (see [_CustomUserAgentCompatMap]). + /// + /// If a client is passed in, it should not be closed manually unless certain + /// that all tile requests have finished, else they will throw + /// [ClientException]s. If the constructor automatically creates a client ( + /// because one was not passed as an argument), it will be closed safely + /// automatically on [dispose]al. /// /// Defaults to a standard [IOClient]/[HttpClient]. final Client httpClient; @@ -251,18 +307,25 @@ class FMTCTileProvider extends TileProvider { super.dispose(); } - /// {@template fmtc.tileProvider.getBytes} + /// {@template fmtc.tileProvider.provideTile} /// Use FMTC's caching logic to get the bytes of the specific tile (at /// [coords]) with the specified [TileLayer] options and [FMTCTileProvider] /// provider /// - /// Used internally by [_FMTCImageProvider.loadImage]. `loadImage` provides - /// a decoding wrapper, but is only suitable for codecs Flutter can render. + /// > [!IMPORTANT] + /// > Note that this will actuate the cache writing mechanism as if a normal + /// > tile browse request was made - ie. the bytes returned may be written to + /// > the cache. /// - /// Therefore, this method does not make any assumptions about the format - /// of the bytes, and it is up to the user to decode/render appropriately. - /// For example, this could be incorporated into another [ImageProvider] (via - /// a [TileProvider]) to integrate FMTC caching for vector tiles. + /// Used internally by [_FMTCImageProvider.loadImage]. `loadImage` provides a + /// decoding wrapper to display the bytes as an image, but is only suitable + /// for codecs Flutter can render. + /// + /// > [!TIP] + /// > This method does not make any assumptions about theformat of the bytes, + /// > and it is up to the user to decode/render appropriately. For example, this + /// > could be incorporated into another [ImageProvider] (via a + /// > [TileProvider]) to integrate FMTC caching for vector tiles. /// /// --- /// @@ -286,7 +349,7 @@ class FMTCTileProvider extends TileProvider { /// FMTC will not throw an error, but Flutter will if the bytes are attempted /// to be decoded (now or at a later time). /// {@endtemplate} - Future getBytes({ + Future provideTile({ required TileCoordinates coords, required TileLayer options, Object? key, @@ -294,7 +357,7 @@ class FMTCTileProvider extends TileProvider { void Function()? finishedLoadingBytes, bool requireValidImage = false, }) => - _FMTCImageProvider.getBytes( + _FMTCImageProvider.provideTile( coords: coords, options: options, provider: this, @@ -311,11 +374,13 @@ class FMTCTileProvider extends TileProvider { Future isTileCached({ required TileCoordinates coords, required TileLayer options, - }) => - FMTCBackendAccess.internal.tileExists( - storeNames: _getSpecifiedStoresOrNull(), - url: urlTransformer(getTileUrl(coords, options)), - ); + }) { + final networkUrl = getTileUrl(coords, options); + return FMTCBackendAccess.internal.tileExists( + storeNames: _getSpecifiedStoresOrNull(), + url: urlTransformer?.call(networkUrl) ?? networkUrl, + ); + } /// Removes key-value pairs from the specified [url], given only the [keys] /// @@ -354,16 +419,15 @@ class FMTCTileProvider extends TileProvider { return mutableUrl; } - /// If [storeNames] contains `null`, returns `null`, otherwise returns all + /// If [stores] contains `null`, returns `null`, otherwise returns all /// non-null names (which cannot be empty) List? _getSpecifiedStoresOrNull() => - otherStoresStrategy != null ? null : storeNames.keys.toList(); + otherStoresStrategy != null ? null : stores.keys.toList(); @override bool operator ==(Object other) => identical(this, other) || (other is FMTCTileProvider && - mapEquals(other.storeNames, storeNames) && other.otherStoresStrategy == otherStoresStrategy && other.loadingStrategy == loadingStrategy && other.useOtherStoresAsFallbackOnly == useOtherStoresAsFallbackOnly && @@ -373,20 +437,23 @@ class FMTCTileProvider extends TileProvider { other.errorHandler == errorHandler && other.tileLoadingInterceptor == tileLoadingInterceptor && other.httpClient == httpClient && - other.headers == headers); + mapEquals(other.stores, stores) && + mapEquals(other.headers, headers)); @override - int get hashCode => Object.hash( - storeNames, - otherStoresStrategy, - loadingStrategy, - useOtherStoresAsFallbackOnly, - recordHitsAndMisses, - cachedValidDuration, - urlTransformer, - errorHandler, - tileLoadingInterceptor, - httpClient, - headers, + int get hashCode => Object.hashAllUnordered( + [ + otherStoresStrategy, + loadingStrategy, + useOtherStoresAsFallbackOnly, + recordHitsAndMisses, + cachedValidDuration, + urlTransformer, + errorHandler, + tileLoadingInterceptor, + httpClient, + ...stores.entries.map((e) => (e.key, e.value)), + ...headers.entries.map((e) => (e.key, e.value)), + ], ); } diff --git a/lib/src/regions/base_region.dart b/lib/src/regions/base_region.dart index 2b309af3..43c2a233 100644 --- a/lib/src/regions/base_region.dart +++ b/lib/src/regions/base_region.dart @@ -8,12 +8,6 @@ part of '../../flutter_map_tile_caching.dart'; /// It can be converted to a: /// - [DownloadableRegion] for downloading: [toDownloadable] /// - list of [LatLng]s forming the outline: [toOutline] -/// -/// Extended/implemented by: -/// - [RectangleRegion] -/// - [CircleRegion] -/// - [LineRegion] -/// - [CustomPolygonRegion] @immutable sealed class BaseRegion { /// Create a geographical region that forms a particular shape @@ -21,12 +15,6 @@ sealed class BaseRegion { /// It can be converted to a: /// - [DownloadableRegion] for downloading: [toDownloadable] /// - list of [LatLng]s forming the outline: [toOutline] - /// - /// Extended/implemented by: - /// - [RectangleRegion] - /// - [CircleRegion] - /// - [LineRegion] - /// - [CustomPolygonRegion] const BaseRegion(); /// Output a value of type [T] the type of this region @@ -34,8 +22,12 @@ sealed class BaseRegion { /// Requires all region types to have a defined handler. See [maybeWhen] for /// the equivalent where this is not required. @Deprecated( - 'Prefer using a pattern matching selection (such as `if case` or ' - '`switch`). This will be removed in a future version.', + 'Use a pattern matching selection pattern (such as `if case` or `switch`) ' + 'instead. ' + 'This is now a redundant method as the `BaseRegion` inheritance tree is ' + 'sealed and modern Dart supports the intended purpose of this natively. ' + 'This feature was deprecated in v10, and will be removed in a future ' + 'version.', ) T when({ required T Function(RectangleRegion rectangle) rectangle, @@ -57,8 +49,12 @@ sealed class BaseRegion { /// If the specified method is not defined for the type of region which this /// region is, `null` will be returned. @Deprecated( - 'Prefer using a pattern matching selection (such as `if case` or ' - '`switch`). This will be removed in a future version.', + 'Use a pattern matching selection pattern (such as `if case` or `switch`) ' + 'instead. ' + 'This is now a redundant method as the `BaseRegion` inheritance tree is ' + 'sealed and modern Dart supports the intended purpose of this natively. ' + 'This feature was deprecated in v10, and will be removed in a future ' + 'version.', ) T? maybeWhen({ T Function(RectangleRegion rectangle)? rectangle, diff --git a/lib/src/root/recovery.dart b/lib/src/root/recovery.dart index 17622bd2..04f5a8ee 100644 --- a/lib/src/root/recovery.dart +++ b/lib/src/root/recovery.dart @@ -31,7 +31,7 @@ part of '../../flutter_map_tile_caching.dart'; /// been successfully downloaded. Therefore, no unnecessary tiles are downloaded /// again. /// -/// > [!NOTE] +/// > [!IMPORTANT] /// > Options set at download time, in [StoreDownload.startForeground], are not /// > included. class RootRecovery { @@ -46,12 +46,10 @@ class RootRecovery { /// {@macro fmtc.backend.watchRecovery} Stream watch({ bool triggerImmediately = false, - }) async* { - final stream = FMTCBackendAccess.internal.watchRecovery( - triggerImmediately: triggerImmediately, - ); - yield* stream; - } + }) => + FMTCBackendAccess.internal.watchRecovery( + triggerImmediately: triggerImmediately, + ); /// List all recoverable regions, and whether each one has failed /// diff --git a/lib/src/root/statistics.dart b/lib/src/root/statistics.dart index 88ec61f0..6b6055d9 100644 --- a/lib/src/root/statistics.dart +++ b/lib/src/root/statistics.dart @@ -22,7 +22,12 @@ class RootStats { Future get length => FMTCBackendAccess.internal.rootLength(); /// {@macro fmtc.backend.watchRecovery} - @Deprecated('This has been moved to `FMTCRoot.recovery` & renamed `.watch`') + @Deprecated( + 'Use `FMTCRoot.recovery.watch()` instead. ' + 'This is more suited to the context of the recovery methods. ' + 'This feature was deprecated in v10, and will be removed in a future ' + 'version.', + ) Stream watchRecovery({ bool triggerImmediately = false, }) => diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index ad74338e..73f63b07 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -7,7 +7,7 @@ part of '../../flutter_map_tile_caching.dart'; /// /// --- /// -/// {@template num_instances} +/// {@template fmtc.bulkDownload.numInstances} /// By default, only one download is allowed at any one time, across all stores. /// /// However, if necessary, multiple can be started by setting methods' @@ -50,10 +50,12 @@ class StoreDownload { /// /// The first stream (of [DownloadProgress]s) will emit events: /// * once per [TileEvent] emitted on the second stream - /// * at intervals of no longer than [maxReportInterval] - /// * once at the start of the download indicating setup is complete and the - /// first tile is being downloaded - /// * once additionally at the end of the download after the last tile + /// * additionally at intervals of no longer than [maxReportInterval] + /// (defaulting to 1 second, to allow time-based statistics to remain + /// up-to-date if no [TileEvent]s are emitted for a while) + /// * additionally once at the start of the download indicating setup is + /// complete and the first tile is being downloaded + /// * additionally once at the end of the download after the last tile /// setting some final statistics (such as tiles per second to 0) /// /// Once the stream of [DownloadProgress]s completes/finishes, the download @@ -122,16 +124,6 @@ class StoreDownload { /// /// --- /// - /// A fresh [DownloadProgress] event will always be emitted every - /// [maxReportInterval] (if specified), which defaults to every 1 second, - /// regardless of whether any more tiles have been attempted/downloaded/failed. - /// This is to enable the [DownloadProgress.elapsedDuration] to be accurately - /// presented to the end user. - /// - /// {@macro fmtc.tileevent.extraConsiderations} - /// - /// --- - /// /// When this download is started, assuming [disableRecovery] is `false` (as /// default), the recovery system will register this download, to allow it to /// be recovered if it unexpectedly fails. @@ -150,7 +142,7 @@ class StoreDownload { /// /// --- /// - /// {@macro num_instances} + /// {@macro fmtc.bulkDownload.numInstances} ({ Stream tileEvents, Stream downloadProgress, @@ -203,13 +195,11 @@ class StoreDownload { final UrlTransformer resolvedUrlTransformer; if (urlTransformer != null) { resolvedUrlTransformer = urlTransformer; + } else if (region.options.tileProvider + case final FMTCTileProvider tileProvider) { + resolvedUrlTransformer = tileProvider.urlTransformer ?? (u) => u; } else { - if (region.options.tileProvider - case final FMTCTileProvider tileProvider) { - resolvedUrlTransformer = tileProvider.urlTransformer; - } else { - resolvedUrlTransformer = (u) => u; - } + resolvedUrlTransformer = (u) => u; } // Create download instance @@ -346,7 +336,13 @@ class StoreDownload { /// /// Note that this does not require an existing/ready store, or a sensical /// [DownloadableRegion.options]. - @Deprecated('`check` has been renamed to `countTiles`') + @Deprecated( + 'Use `countTiles()` instead. ' + 'The new name is less ambiguous and aligns better with recommended Dart ' + 'code style. ' + 'This feature was deprecated in v10, and will be removed in a future ' + 'version.', + ) Future check(DownloadableRegion region) => countTiles(region); /// Cancel the ongoing foreground download and recovery session @@ -357,7 +353,7 @@ class StoreDownload { /// cancel the download immediately, as this would likely cause unwanted /// behaviour. /// - /// {@macro num_instances} + /// {@macro fmtc.bulkDownload.numInstances} /// /// Does nothing (returns immediately) if there is no ongoing download. Future cancel({Object instanceId = 0}) async => @@ -372,7 +368,7 @@ class StoreDownload { /// parallel download threads will be allowed to finish their *current* tile /// download. Any buffered tiles are not written. /// - /// {@macro num_instances} + /// {@macro fmtc.bulkDownload.numInstances} /// /// Does nothing (returns immediately) if there is no ongoing download or the /// download is already paused. @@ -384,7 +380,7 @@ class StoreDownload { /// Resume (after a [pause]) the ongoing foreground download /// - /// {@macro num_instances} + /// {@macro fmtc.bulkDownload.numInstances} /// /// Does nothing if there is no ongoing download or the download is already /// running. @@ -397,7 +393,7 @@ class StoreDownload { /// Whether the ongoing foreground download is currently paused after a call /// to [pause] (and prior to [resume]) /// - /// {@macro num_instances} + /// {@macro fmtc.bulkDownload.numInstances} /// /// Also returns `false` if there is no ongoing download. bool isPaused({Object instanceId = 0}) => diff --git a/lib/src/store/statistics.dart b/lib/src/store/statistics.dart index 67f01e02..dfe6f234 100644 --- a/lib/src/store/statistics.dart +++ b/lib/src/store/statistics.dart @@ -14,7 +14,7 @@ class StoreStats { /// {@macro fmtc.backend.getStoreStats} /// - /// {@template fmtc.frontend.storestats.efficiency} + /// {@template fmtc.storeStats.efficiency} /// Prefer using [all] when multiple statistics are required instead of /// getting them individually. Only one backend operation is required to get /// all the stats, and so is more efficient. @@ -25,19 +25,19 @@ class StoreStats { /// Retrieve the total number of KiBs of all tiles' bytes (not 'real total' /// size) /// - /// {@macro fmtc.frontend.storestats.efficiency} + /// {@macro fmtc.storeStats.efficiency} Future get size => all.then((a) => a.size); /// Retrieve the number of tiles belonging to this store /// - /// {@macro fmtc.frontend.storestats.efficiency} + /// {@macro fmtc.storeStats.efficiency} Future get length => all.then((a) => a.length); /// Retrieve the number of successful tile retrievals when browsing /// /// A hit is only counted when an unexpired tile is retrieved from the store. /// - /// {@macro fmtc.frontend.storestats.efficiency} + /// {@macro fmtc.storeStats.efficiency} Future get hits => all.then((a) => a.hits); /// Retrieve the number of unsuccessful tile retrievals when browsing @@ -45,7 +45,7 @@ class StoreStats { /// A miss is counted whenever a tile is retrieved anywhere else but from this /// store, or is retrieved from this store, but only as a fallback. /// - /// {@macro fmtc.frontend.storestats.efficiency} + /// {@macro fmtc.storeStats.efficiency} Future get misses => all.then((a) => a.misses); /// {@macro fmtc.backend.watchStores} diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart index 1ab6c5c4..6b89217c 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -38,11 +38,17 @@ class FMTCStore { /// Generate an [FMTCTileProvider] that only specifies this store /// - /// See other available [FMTCTileProvider] contructors to use multiple stores - /// at once. See [FMTCTileProvider] for more info. + /// Prefer/migrate to the [FMTCTileProvider.new] constructor. /// - /// [FMTCTileProvider.fakeNetworkDisconnect] cannot be set through this - /// shorthand for [FMTCTileProvider.multipleStores]. + /// {@macro fmtc.fmtcTileProvider.constructionTip} + @Deprecated( + 'Use the `FMTCTileProvider` default constructor instead. ' + 'This will reduce internal codebase complexity and maximise external ' + 'flexibility, and works toward a potential future decentralised API ' + 'design. ' + 'This feature was deprecated in v10, and will be removed in a future ' + 'version.', + ) FMTCTileProvider getTileProvider({ BrowseStoreStrategy storeStrategy = BrowseStoreStrategy.readUpdateCreate, BrowseStoreStrategy? otherStoresStrategy, @@ -56,8 +62,8 @@ class FMTCStore { Map? headers, Client? httpClient, }) => - FMTCTileProvider.multipleStores( - storeNames: {storeName: storeStrategy}, + FMTCTileProvider( + stores: {storeName: storeStrategy}, otherStoresStrategy: otherStoresStrategy, loadingStrategy: loadingStrategy, useOtherStoresAsFallbackOnly: useOtherStoresAsFallbackOnly, From ee0819976849df6e2f38ea471f82f338dc7a8ba9 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 30 Dec 2024 21:32:45 +0000 Subject: [PATCH 83/97] Prepared for final prerelease --- CHANGELOG.md | 24 +++++++++++++----------- pubspec.yaml | 6 +++--- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd91b56a..3d367490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,16 +18,16 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons # Changelog -## [10.0.0] - "Better Browsing" - 2024/XX/XX +## [10.0.0] - "Better Browsing" - 2025/XX/XX This update builds on v9 to fully embrace the new many-to-many relationship between tiles and stores, which allows for more flexibility when constructing the `FMTCTileProvider`. This allows a new paradigm to be used: stores may now be treated as bulk downloaded regions, and all the regions/stores can be used at once - no more switching between them. This allows huge amounts of flexibility and a better UX in a complex application. Additionally, each store may now have its own `BrowseStoreStrategy` when browsing, which allows more flexibility: for example, stores may now contain more than one URL template/source, but control is retained. Additionally, vector tiles are now supported in theory, as the internal caching/retrieval logic of the specialised `ImageProvider` has been exposed, although it is out of scope to fully implement support for it. -* Improvements to the browse caching logic and customizability +* Major changes to browse caching * Added support for using multiple stores simultaneously in the `FMTCTileProvider` (through the `FMTCTileProvider.allStores` & `FMTCTileProvider.multipleStores` constructors) - * Added `FMTCTileProvider.getBytes` method to expose internal caching mechanisms for external use + * Added `FMTCTileProvider.provideTile` method to expose internal browse caching mechanisms for external use * Added `BrowseStoreStrategy` for increased control over caching behaviour * Added 'tile loading interceptor' feature (`FMTCTileProvider.tileLoadingInterceptor`) to track (eg. for debugging and logging) the internal tile loading mechanisms * Added toggle for hit/miss stat recording, to improve performance where these statistics are never read @@ -39,17 +39,19 @@ Additionally, vector tiles are now supported in theory, as the internal caching/ * Performance of the internal tile image provider has been significantly improved when fetching images from the network URL > There was a significant time loss due to attempting to handle the network request response as a stream of incoming bytes, which allowed for `chunkEvents` to be reported back to Flutter (allowing it to get progress updates on the state of the tile), but meant the bytes had to be collected and built manually. Removing this functionality allows the network requests to use more streamlined 'package:http' methods, which does not expose a stream of incoming bytes, meaning that bytes no longer have to be treated manually. This can save hundreds of milliseconds on tile loading - a significant time save of potentially up to ~50% in some cases! -* Improvements, additions, and removals for bulk downloadable `BaseRegion`s +* Major changes to bulk downloading + * Added support for retrying failed tiles (that failed because the request could not be made) once at the end of the download + * Changed result of `StoreDownload.startForeground` into two seperate streams returned as a record, one for `TileEvent`s, one for `DownloadProgress`s + * Refactored `TileEvent`s into multiple classes and mixins in a sealed inheritance tree to reduce nullability and uncertainty & promote modern Dart features + * Changed `DownloadProgress`' metrics to reflect other changes and renamed methods to improve clarity and consistency with Dart recommended style + * Renamed `StoreDownload.check` to `.countTiles` + +* Improvements for bulk downloadable `BaseRegion`s * Added `MultiRegion`, which contains multiple other `BaseRegion`s * Improved speed (by massive amounts) and accuracy & reduced memory consumption of `CircleRegion`'s tile generation & counting algorithm + * Fixed multiple bugs with respect to `start` and `end` tiles in downloads * Deprecated `BaseRegion.(maybe)When` - this is easy to perform using a standard pattern-matched switch -* Major changes to bulk downloading - * Added support for retrying failed tiles (that failed because the request could not be made) once at the end of the download - * `StoreDownload.startForeground` output stream split into two streams returned as a record, one for `TileEvent`s, one for `DownloadProgress`s - * `TileEvents` has been split up into multiple classes and mixins to reduce nullability and uncertainty - * `DownloadProgress` has had its contained metrics changed to reflect the failed tiles retry, and `latestTileEvent` removed - * Exporting stores is now more stable, and has improved documentation > The method now works in a dedicated temporary environment and attempts to perform two different strategies to move/copy-and-delete the result to the specified directory at the end before failing. Improved documentation covers the potential pitfalls of permissions and now recommends exporting to an app directory, then using the system share functionality on some devices. It now also returns the number of exported tiles. @@ -57,7 +59,7 @@ Additionally, vector tiles are now supported in theory, as the internal caching/ * Other generic improvements (performance, stability, and documentation) -* Brand new example app to (partially!) showcase the new levels of flexibility and customizability +* Brand new example app to demonstrate the new levels of flexibility and customizability ## [9.1.4] - 2024/12/05 diff --git a/pubspec.yaml b/pubspec.yaml index c5b4dcbd..113f8a53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 10.0.0-dev.6 +version: 10.0.0-dev.7 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues @@ -43,9 +43,9 @@ dependencies: path_provider: ^2.1.4 dev_dependencies: - build_runner: ^2.4.13 + build_runner: ^2.4.14 objectbox_generator: ^4.0.3 - test: ^1.25.8 + test: ^1.25.14 flutter: null From 012db03e3a3a8fec27239460068720bfed2d17a2 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 1 Jan 2025 17:38:43 +0000 Subject: [PATCH 84/97] Added respect for downloading output streams' status Improved documentation Minor example app improvements --- .../loading_behaviour_selector.dart | 38 ++--- .../components/tiles/unspecified_tile.dart | 15 +- .../src/shared/state/download_provider.dart | 4 +- .../bulk_download/internal/control_cmds.dart | 4 + lib/src/bulk_download/internal/manager.dart | 150 ++++++++++------- .../tile_provider/tile_provider.dart | 25 ++- lib/src/store/download.dart | 151 ++++++++++++------ 7 files changed, 230 insertions(+), 157 deletions(-) diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/components/loading_behaviour_selector.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/components/loading_behaviour_selector.dart index c80ebe7d..a4b453d4 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/components/loading_behaviour_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/components/loading_behaviour_selector.dart @@ -14,8 +14,17 @@ class LoadingBehaviourSelector extends StatelessWidget { Selector( selector: (context, provider) => provider.loadingStrategy, builder: (context, loadingStrategy, _) => Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Padding( + padding: const EdgeInsets.only(left: 18), + child: Text( + 'Preferred Loading Strategy', + style: Theme.of(context).textTheme.labelMedium, + ), + ), + const SizedBox(height: 4), SizedBox( width: double.infinity, child: SegmentedButton( @@ -28,12 +37,12 @@ class LoadingBehaviourSelector extends StatelessWidget { ButtonSegment( value: BrowseLoadingStrategy.cacheFirst, icon: Icon(Icons.storage_rounded), - label: Text('Cache'), + label: Text('Cache First'), ), ButtonSegment( value: BrowseLoadingStrategy.onlineFirst, icon: Icon(Icons.public_rounded), - label: Text('Network'), + label: Text('Online First'), ), ], selected: {loadingStrategy}, @@ -46,31 +55,6 @@ class LoadingBehaviourSelector extends StatelessWidget { ), ), const SizedBox(height: 6), - /*Selector( - selector: (context, provider) => - provider.behaviourUpdateFromNetwork, - builder: (context, behaviourUpdateFromNetwork, _) => Row( - children: [ - const SizedBox(width: 8), - const Text('Update cache when network used'), - const Spacer(), - Switch.adaptive( - value: cacheBehavior != null && behaviourUpdateFromNetwork, - onChanged: cacheBehavior == null - ? null - : (value) => context - .read() - .behaviourUpdateFromNetwork = value, - thumbIcon: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.selected) - ? const Icon(Icons.edit) - : const Icon(Icons.edit_off), - ), - ), - const SizedBox(width: 8), - ], - ), - ),*/ ], ), ); diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart index 16f6907f..006d88bb 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.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/misc/internal_store_read_write_behaviour.dart'; @@ -21,11 +22,15 @@ class _UnspecifiedTileState extends State { @override Widget build(BuildContext context) { final isAllUnselectedDisabled = context - .select( - (provider) => provider.currentStores['(unspecified)'], - ) - ?.toBrowseStoreStrategy() == - null; + .select( + (p) => p.currentStores['(unspecified)'], + ) + ?.toBrowseStoreStrategy() == + null || + context.select( + (p) => p.loadingStrategy, + ) == + BrowseLoadingStrategy.onlineFirst; return RepaintBoundary( child: Material( diff --git a/example/lib/src/shared/state/download_provider.dart b/example/lib/src/shared/state/download_provider.dart index 81535d64..281a8516 100644 --- a/example/lib/src/shared/state/download_provider.dart +++ b/example/lib/src/shared/state/download_provider.dart @@ -46,12 +46,14 @@ class DownloadingProvider extends ChangeNotifier { _rawTileEventsStream = downloadStreams.tileEvents.asBroadcastStream(); + bool isFirstEvent = true; downloadStreams.downloadProgress.listen( (evt) { // Focus on initial event - if (evt.attemptedTilesCount == 0) { + if (isFirstEvent) { _isFocused = true; focused.complete(); + isFirstEvent = false; } // Update stored value diff --git a/lib/src/bulk_download/internal/control_cmds.dart b/lib/src/bulk_download/internal/control_cmds.dart index 88bb4c95..90cea268 100644 --- a/lib/src/bulk_download/internal/control_cmds.dart +++ b/lib/src/bulk_download/internal/control_cmds.dart @@ -7,4 +7,8 @@ enum _DownloadManagerControlCmd { cancel, resume, pause, + startEmittingDownloadProgress, + stopEmittingDownloadProgress, + startEmittingTileEvents, + stopEmittingTileEvents, } diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index c4e77404..6687c95d 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -174,7 +174,7 @@ Future _downloadManager( // Setup two-way communications with root final rootReceivePort = ReceivePort(); - void sendToMain(Object? m) => input.sendPort.send(m); + void sendToRoot(Object? m) => input.sendPort.send(m); // Setup cancel, pause, and resume handling Iterable> generateThreadPausedStates() => Iterable.generate( @@ -184,48 +184,75 @@ Future _downloadManager( final threadPausedStates = generateThreadPausedStates().toList(); final cancelSignal = Completer(); var pauseResumeSignal = Completer()..complete(); + + // Setup efficient output handling + bool shouldEmitDownloadProgress = false; + bool shouldEmitTileEvents = false; + + // Setup progress report fallback + Timer? fallbackProgressEmitter; + void emitLastDownloadProgressUpdated() => sendToRoot( + lastDownloadProgress = lastDownloadProgress._updateWithoutTile( + elapsedDuration: downloadDuration.elapsed, + tilesPerSecond: getCurrentTPS(registerNewTPS: false), + ), + ); + void restartFallbackProgressEmitter() { + if (input.maxReportInterval case final interval?) { + fallbackProgressEmitter = Timer.periodic( + interval, + (_) => emitLastDownloadProgressUpdated(), + ); + } + emitLastDownloadProgressUpdated(); + } + + // Listen to the root comms port rootReceivePort.listen( (cmd) async { + if (cmd is! _DownloadManagerControlCmd) { + throw UnsupportedError('Recieved unknown control cmd: $cmd'); + } + switch (cmd) { case _DownloadManagerControlCmd.cancel: - try { - cancelSignal.complete(); - // If the signal is already complete, that's fine - // ignore: avoid_catching_errors, empty_catches - } on StateError {} + if (!cancelSignal.isCompleted) cancelSignal.complete(); + // We might recieve it more than once if the root requests cancellation + // whilst we already are cancelling it case _DownloadManagerControlCmd.pause: + if (!pauseResumeSignal.isCompleted) { + // We might recieve it more than once if the root requests pausing + // whilst we already are pausing it + break; + } + pauseResumeSignal = Completer(); threadPausedStates.setAll(0, generateThreadPausedStates()); await Future.wait(threadPausedStates.map((e) => e.future)); + downloadDuration.stop(); - sendToMain(_DownloadManagerControlCmd.pause); + fallbackProgressEmitter?.cancel(); + if (shouldEmitDownloadProgress) emitLastDownloadProgressUpdated(); + + sendToRoot(_DownloadManagerControlCmd.pause); case _DownloadManagerControlCmd.resume: - pauseResumeSignal.complete(); + if (shouldEmitDownloadProgress) restartFallbackProgressEmitter(); downloadDuration.start(); - default: - throw UnimplementedError('Recieved unknown control cmd: $cmd'); + pauseResumeSignal.complete(); + case _DownloadManagerControlCmd.startEmittingDownloadProgress: + shouldEmitDownloadProgress = true; + restartFallbackProgressEmitter(); + case _DownloadManagerControlCmd.stopEmittingDownloadProgress: + shouldEmitDownloadProgress = false; + fallbackProgressEmitter?.cancel(); + case _DownloadManagerControlCmd.startEmittingTileEvents: + shouldEmitTileEvents = true; + case _DownloadManagerControlCmd.stopEmittingTileEvents: + shouldEmitTileEvents = false; } }, ); - // Setup progress report fallback - final fallbackReportTimer = input.maxReportInterval == null - ? null - : Timer.periodic( - input.maxReportInterval!, - (_) { - if (lastDownloadProgress != initialDownloadProgress && - pauseResumeSignal.isCompleted) { - sendToMain( - lastDownloadProgress = lastDownloadProgress._updateWithoutTile( - elapsedDuration: downloadDuration.elapsed, - tilesPerSecond: getCurrentTPS(registerNewTPS: false), - ), - ); - } - }, - ); - // Start recovery system (unless disabled) if (input.recoveryId case final recoveryId?) { await input.backend.initialise(); @@ -252,10 +279,11 @@ Future _downloadManager( final threadBackend = input.backend.duplicate(); // Now it's safe, start accepting communications from the root - sendToMain(rootReceivePort.sendPort); + sendToRoot(rootReceivePort.sendPort); // Send an initial progress report to indicate the start of the download - sendToMain(initialDownloadProgress); + // if (shouldEmitDownloadProgress) sendToRoot(initialDownloadProgress); + // This is done implicitly on listening to the output, so is unnecessary // Start download threads & wait for download to complete/cancelled downloadDuration.start(); @@ -302,8 +330,8 @@ Future _downloadManager( (evt) async { // Thread is sending tile data if (evt is TileEvent) { - // Send event to user - sendToMain(evt); + // Send event to root if necessary + if (shouldEmitTileEvents) sendToRoot(evt); // Queue tiles for retry if failed and not already a retry attempt if (input.retryFailedRequestTiles && @@ -333,40 +361,43 @@ Future _downloadManager( } } - final wasBufferFlushed = - evt is SuccessfulTileEvent && evt._wasBufferFlushed; - - sendToMain( - lastDownloadProgress = lastDownloadProgress._updateWithTile( - bufferedTiles: evt is SuccessfulTileEvent - ? ( - count: threadBuffersTiles.reduce((a, b) => a + b), - size: threadBuffersSize.reduce((a, b) => a + b) / - 1024, - ) - : null, - newTileEvent: evt, - elapsedDuration: downloadDuration.elapsed, - tilesPerSecond: getCurrentTPS(registerNewTPS: true), - ), + // Update download progress and send to root if necessary + lastDownloadProgress = lastDownloadProgress._updateWithTile( + bufferedTiles: evt is SuccessfulTileEvent + ? ( + count: threadBuffersTiles.reduce((a, b) => a + b), + size: + threadBuffersSize.reduce((a, b) => a + b) / 1024, + ) + : null, + newTileEvent: evt, + elapsedDuration: downloadDuration.elapsed, + tilesPerSecond: getCurrentTPS(registerNewTPS: true), ); + if (shouldEmitDownloadProgress) { + sendToRoot(lastDownloadProgress); + } // For efficiency, only update recovery when the buffer is // cleaned // We don't want to update recovery to a tile that isn't cached // (only buffered), because they'll be lost in the events // recovery is designed to recover from - if (wasBufferFlushed) updateRecovery(); + if (evt is SuccessfulTileEvent && evt._wasBufferFlushed) { + updateRecovery(); + } } else { // We do not need to care about buffering, which makes updates // much easier - sendToMain( - lastDownloadProgress = lastDownloadProgress._updateWithTile( - newTileEvent: evt, - elapsedDuration: downloadDuration.elapsed, - tilesPerSecond: getCurrentTPS(registerNewTPS: true), - ), + + lastDownloadProgress = lastDownloadProgress._updateWithTile( + newTileEvent: evt, + elapsedDuration: downloadDuration.elapsed, + tilesPerSecond: getCurrentTPS(registerNewTPS: true), ); + if (shouldEmitDownloadProgress) { + sendToRoot(lastDownloadProgress); + } updateRecovery(); } @@ -433,14 +464,13 @@ Future _downloadManager( // Send final progress update downloadDuration.stop(); - sendToMain( - lastDownloadProgress = lastDownloadProgress._updateToComplete( - elapsedDuration: downloadDuration.elapsed, - ), + lastDownloadProgress = lastDownloadProgress._updateToComplete( + elapsedDuration: downloadDuration.elapsed, ); + if (shouldEmitDownloadProgress) sendToRoot(lastDownloadProgress); // Cleanup resources and shutdown - fallbackReportTimer?.cancel(); + fallbackProgressEmitter?.cancel(); rootReceivePort.close(); if (input.recoveryId != null) await input.backend.uninitialise(); tileIsolate.kill(priority: Isolate.immediate); diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index cb41f603..e75a7a7e 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -176,25 +176,17 @@ class FMTCTileProvider extends TileProvider { /// Method used to create a tile's storage-suitable UID from it's real URL /// + /// For more information, check the + /// [online documentation](https://fmtc.jaffaketchup.dev/basic-usage/integrating-with-a-map#ensure-tiles-are-resilient-to-url-changes). + /// /// The input string is the tile's URL. The output string should be a unique /// string to that tile that will remain as stable as necessary if parts of /// the URL not directly related to the tile image change. /// - /// For more information, see: - /// . - /// /// [urlTransformerOmitKeyValues] may be used as a transformer to omit entire /// key-value pairs from a URL where the key matches one of the specified /// keys. /// - /// > [!IMPORTANT] - /// > The callback will be passed to a different isolate: therefore, avoid - /// > using any external state that may not be properly captured or cannot be - /// > copied to an isolate spawned with [Isolate.spawn] (see [SendPort.send]). - /// - /// _Internally, the storage-suitable UID is usually referred to as the tile - /// URL (with distinction inferred)._ - /// /// By default, the output string is the input string - that is, the /// storage-suitable UID is the tile's real URL. final UrlTransformer? urlTransformer; @@ -419,10 +411,13 @@ class FMTCTileProvider extends TileProvider { return mutableUrl; } - /// If [stores] contains `null`, returns `null`, otherwise returns all - /// non-null names (which cannot be empty) - List? _getSpecifiedStoresOrNull() => - otherStoresStrategy != null ? null : stores.keys.toList(); + // TODO: This does not work correctly. Needs a complex system like writing. + List? _getSpecifiedStoresOrNull() => otherStoresStrategy != null + ? null + : /*stores.keys.toList()*/ stores.entries + .where((e) => e.value != null) + .map((e) => e.key) + .toList(); @override bool operator ==(Object other) => diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 73f63b07..3486ad43 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -34,19 +34,10 @@ class StoreDownload { /// Download a specified [DownloadableRegion] in the foreground, with a /// recovery session by default /// - /// > [!TIP] - /// > To count the number of tiles in a region before starting a download, use - /// > [countTiles]. - /// - /// --- - /// - /// Outputs two non-broadcast streams. - /// - /// One emits [DownloadProgress]s which contain stats and info about the whole - /// download. - /// - /// One emits [TileEvent]s which contain info about the most recent tile - /// attempted only. + /// Outputs two non-broadcast streams. One emits [DownloadProgress]s which + /// contain stats and info about the whole download. The other emits + /// [TileEvent]s which contain info about the most recent tile attempted only. + /// They only emit events when listened to. /// /// The first stream (of [DownloadProgress]s) will emit events: /// * once per [TileEvent] emitted on the second stream @@ -57,16 +48,33 @@ class StoreDownload { /// complete and the first tile is being downloaded /// * additionally once at the end of the download after the last tile /// setting some final statistics (such as tiles per second to 0) + /// * additionally when pausing and resuming the download, as well as after + /// listening to the stream + /// + /// The completion/finish of the [DownloadProgress] stream implies the + /// completion of the download, even if the last + /// [DownloadProgress.percentageProgress] is not 100(%). /// - /// Once the stream of [DownloadProgress]s completes/finishes, the download - /// has stopped. + /// The second stream (of [TileEvent]s) will emit events for every tile + /// download attempt. /// - /// Neither output stream respects listen, pause, resume, or cancel events - /// when submitted through the stream subscription. - /// The download will start when this method is invoked, irrespective of - /// whether there are listeners. The download will continue irrespective of - /// listeners. The only control methods are via FMTC's [pause], [resume], and - /// [cancel] methods. + /// > [!IMPORTANT] + /// > + /// > An emitted [TileEvent] may refer to a tile for which an event has been + /// > emitted previously. + /// > + /// > This will be the case when [TileEvent.wasRetryAttempt] is `true`, which + /// > may occur only if [retryFailedRequestTiles] is enabled. + /// + /// Listening, pausing, resuming, or cancelling subscriptions to the output + /// streams will not start, pause, resume, or cancel the download. It will + /// only change the output stream. Not listening to a stream may improve the + /// efficiency of the download a negligible amount. + /// + /// To control the download itself, use [pause], [resume], and [cancel]. + /// + /// The download starts when this method is invoked: it does not wait for + /// listneners. /// /// --- /// @@ -93,7 +101,7 @@ class StoreDownload { /// > currently in the buffer. It will also increase the memory (RAM) /// > required. /// - /// > [!WARNING] + /// > [!IMPORTANT] /// > Skipping sea tiles will not reduce the number of downloads - tiles must /// > be downloaded to be compared against the sample sea tile. It is only /// > designed to reduce the storage capacity consumed. @@ -132,10 +140,24 @@ class StoreDownload { /// /// --- /// - /// For info about [urlTransformer], see [FMTCTileProvider.urlTransformer]. - /// If unspecified, and the [region]'s [DownloadableRegion.options] is an - /// [FMTCTileProvider], will default to that tile provider's `urlTransformer` - /// if specified. Otherwise, will default to the identity function. + /// For info about [urlTransformer], see [FMTCTileProvider.urlTransformer] and + /// the + /// [online documentation](https://fmtc.jaffaketchup.dev/basic-usage/integrating-with-a-map#ensure-tiles-are-resilient-to-url-changes). + /// + /// > [!WARNING] + /// > + /// > The callback will be passed to a different isolate: therefore, avoid + /// > using any external state that may not be properly captured or cannot be + /// > copied to an isolate spawned with [Isolate.spawn] (see [SendPort.send]). + /// > + /// > Ideally, the callback should be state-indepedent. + /// + /// If unspecified, and the [region]'s [DownloadableRegion.options] + /// [TileLayer.tileProvider] is a [FMTCTileProvider] with a defined + /// [FMTCTileProvider.urlTransformer], this will default to that transformer. + /// Otherwise, will default to the identity function. + /// + /// --- /// /// To set additional headers, set it via [TileProvider.headers] when /// constructing the [DownloadableRegion]. @@ -218,10 +240,59 @@ class StoreDownload { : Object.hash(instanceId, DateTime.timestamp().millisecondsSinceEpoch); if (!disableRecovery) FMTCRoot.recovery._downloadsOngoing.add(recoveryId!); + // Prepare send port completer + // We use a completer to ensure that the user's request is met as soon as + // possible and is not dropped if the download has not setup yet + final sendPortCompleter = Completer(); + // Prepare output streams - final tileEventsStreamController = StreamController(); - final downloadProgressStreamController = - StreamController(); + // The statuses of the output streams does not control the download itself, + // but for efficiency, we don't emit events that the user will not hear + // We do not filter in the main thread, for added efficiency, we instead + // make the decision directly at source, so copying between Isolates is + // avoided if unnecessary + // We treat listen & resume and cancel & pause as the same event + final downloadProgressStreamController = StreamController( + onListen: () async => (await sendPortCompleter.future) + .send(_DownloadManagerControlCmd.startEmittingDownloadProgress), + onResume: () async => (await sendPortCompleter.future) + .send(_DownloadManagerControlCmd.startEmittingDownloadProgress), + onPause: () async => (await sendPortCompleter.future) + .send(_DownloadManagerControlCmd.stopEmittingDownloadProgress), + onCancel: () async => (await sendPortCompleter.future) + .send(_DownloadManagerControlCmd.stopEmittingDownloadProgress), + ); + final tileEventsStreamController = StreamController( + onListen: () async => (await sendPortCompleter.future) + .send(_DownloadManagerControlCmd.startEmittingTileEvents), + onResume: () async => (await sendPortCompleter.future) + .send(_DownloadManagerControlCmd.startEmittingTileEvents), + onPause: () async => (await sendPortCompleter.future) + .send(_DownloadManagerControlCmd.stopEmittingTileEvents), + onCancel: () async => (await sendPortCompleter.future) + .send(_DownloadManagerControlCmd.stopEmittingTileEvents), + ); + + // Prepare control mechanisms + final cancelCompleter = Completer(); + Completer? pauseCompleter; + sendPortCompleter.future.then( + (sp) => instance + ..requestCancel = () { + sp.send(_DownloadManagerControlCmd.cancel); + return cancelCompleter.future; + } + ..requestPause = () { + sp.send(_DownloadManagerControlCmd.pause); + // Completed by handler above + return (pauseCompleter = Completer()).future + ..then((_) => instance.isPaused = true); + } + ..requestResume = () { + sp.send(_DownloadManagerControlCmd.resume); + instance.isPaused = false; + }, + ); () async { // Start download thread @@ -247,10 +318,6 @@ class StoreDownload { debugName: '[FMTC] Master Bulk Download Thread', ); - // Setup control mechanisms (completers) - final cancelCompleter = Completer(); - Completer? pauseCompleter; - await for (final evt in receivePort) { // Handle new download progress if (evt is DownloadProgress) { @@ -275,25 +342,11 @@ class StoreDownload { // Setup control mechanisms (senders) if (evt is SendPort) { - instance - ..requestCancel = () { - evt.send(_DownloadManagerControlCmd.cancel); - return cancelCompleter.future; - } - ..requestPause = () { - evt.send(_DownloadManagerControlCmd.pause); - // Completed by handler above - return (pauseCompleter = Completer()).future - ..then((_) => instance.isPaused = true); - } - ..requestResume = () { - evt.send(_DownloadManagerControlCmd.resume); - instance.isPaused = false; - }; + sendPortCompleter.complete(evt); continue; } - throw UnimplementedError('Unrecognised message'); + throw UnsupportedError('Unrecognised message: $evt'); } // Handle shutdown (both normal and cancellation) From 04423de98e57d8aa6508f3b0fe2b5a61aa71faf6 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 1 Jan 2025 22:50:49 +0000 Subject: [PATCH 85/97] Added support for resuming a download whilst pausing --- .../src/shared/state/download_provider.dart | 5 +- lib/src/bulk_download/internal/instance.dart | 4 + lib/src/bulk_download/internal/manager.dart | 11 +-- lib/src/store/download.dart | 83 ++++++++++++++----- 4 files changed, 72 insertions(+), 31 deletions(-) diff --git a/example/lib/src/shared/state/download_provider.dart b/example/lib/src/shared/state/download_provider.dart index 281a8516..e19c2307 100644 --- a/example/lib/src/shared/state/download_provider.dart +++ b/example/lib/src/shared/state/download_provider.dart @@ -7,8 +7,7 @@ class DownloadingProvider extends ChangeNotifier { bool _isFocused = false; bool get isFocused => _isFocused; - bool _isPaused = false; - bool get isPaused => _isPaused; + bool get isPaused => FMTCStore(storeName!).download.isPaused(); bool _isComplete = false; bool get isComplete => _isComplete; @@ -78,14 +77,12 @@ class DownloadingProvider extends ChangeNotifier { Future pause() async { assert(_storeName != null, 'Download not in progress'); await FMTCStore(_storeName!).download.pause(); - _isPaused = true; notifyListeners(); } void resume() { assert(_storeName != null, 'Download not in progress'); FMTCStore(_storeName!).download.resume(); - _isPaused = false; notifyListeners(); } diff --git a/lib/src/bulk_download/internal/instance.dart b/lib/src/bulk_download/internal/instance.dart index e6bcef87..6a0bc21f 100644 --- a/lib/src/bulk_download/internal/instance.dart +++ b/lib/src/bulk_download/internal/instance.dart @@ -1,6 +1,8 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE +import 'dart:async'; + import 'package:meta/meta.dart'; @internal @@ -16,6 +18,8 @@ class DownloadInstance { final Object id; bool isPaused = false; + Completer? resumingAfterPause; + Completer pausingCompleter = Completer()..complete(true); // The following callbacks are defined by the `StoreDownload.startForeground` // method, when a download is started, and are tied to that download operation diff --git a/lib/src/bulk_download/internal/manager.dart b/lib/src/bulk_download/internal/manager.dart index 6687c95d..62dd3e69 100644 --- a/lib/src/bulk_download/internal/manager.dart +++ b/lib/src/bulk_download/internal/manager.dart @@ -216,15 +216,12 @@ Future _downloadManager( switch (cmd) { case _DownloadManagerControlCmd.cancel: + // We might recieve it more than once if the root requests + // cancellation whilst we already are cancelling it if (!cancelSignal.isCompleted) cancelSignal.complete(); - // We might recieve it more than once if the root requests cancellation - // whilst we already are cancelling it case _DownloadManagerControlCmd.pause: - if (!pauseResumeSignal.isCompleted) { - // We might recieve it more than once if the root requests pausing - // whilst we already are pausing it - break; - } + // We are already pausing or paused + if (!pauseResumeSignal.isCompleted) return; pauseResumeSignal = Completer(); threadPausedStates.setAll(0, generateThreadPausedStates()); diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 3486ad43..376f7e7c 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -284,9 +284,8 @@ class StoreDownload { } ..requestPause = () { sp.send(_DownloadManagerControlCmd.pause); - // Completed by handler above - return (pauseCompleter = Completer()).future - ..then((_) => instance.isPaused = true); + // Completed by handler below + return (pauseCompleter = Completer()).future; } ..requestResume = () { sp.send(_DownloadManagerControlCmd.resume); @@ -417,38 +416,82 @@ class StoreDownload { /// Use [resume] to resume the download. It is also safe to use [cancel] /// without resuming first. /// - /// Will return once the pause operation is complete. Note that all running - /// parallel download threads will be allowed to finish their *current* tile - /// download. Any buffered tiles are not written. + /// Note that all running parallel download threads will be allowed to finish + /// their *current* tile download before pausing. /// - /// {@macro fmtc.bulkDownload.numInstances} + /// It is not usually necessary to use the result. Returns `null` if there is + /// no ongoing download or the download is already paused or pausing. + /// Otherwise returns whether the download was paused (`false` if [resume] is + /// called whilst the download is being paused). /// - /// Does nothing (returns immediately) if there is no ongoing download or the - /// download is already paused. - Future pause({Object instanceId = 0}) async { + /// Any buffered tiles are not flushed. + /// + /// --- + /// + /// {@macro fmtc.bulkDownload.numInstances} + Future pause({Object instanceId = 0}) { final instance = DownloadInstance.get(instanceId); - if (instance == null || instance.isPaused) return; - await instance.requestPause!.call(); + if (instance == null || + instance.isPaused || + !instance.pausingCompleter.isCompleted || + instance.requestPause == null) { + return SynchronousFuture(null); + } + + instance + ..pausingCompleter = Completer() + ..resumingAfterPause = Completer(); + + instance.requestPause!().then((_) { + instance.pausingCompleter.complete(true); + if (!instance.resumingAfterPause!.isCompleted) instance.isPaused = true; + instance.resumingAfterPause = null; + }); + + return Future.any( + [instance.resumingAfterPause!.future, instance.pausingCompleter.future], + ); } /// Resume (after a [pause]) the ongoing foreground download /// - /// {@macro fmtc.bulkDownload.numInstances} + /// It is not usually necessary to use the result. Returns `null` if there is + /// no ongoing download or the download is already running. Returns `true` if + /// the download was paused. Returns `false` if the download was paus*ing* ( + /// in which case the download will not be paused). + /// + /// --- /// - /// Does nothing if there is no ongoing download or the download is already - /// running. - void resume({Object instanceId = 0}) { + /// {@macro fmtc.bulkDownload.numInstances} + bool? resume({Object instanceId = 0}) { final instance = DownloadInstance.get(instanceId); - if (instance == null || !instance.isPaused) return; - instance.requestResume!.call(); + if (instance == null || + (!instance.isPaused && instance.resumingAfterPause == null) || + instance.requestResume == null) { + return null; + } + + if (instance.pausingCompleter.isCompleted) { + instance.requestResume!(); + return true; + } + + if (!instance.resumingAfterPause!.isCompleted) { + instance + ..resumingAfterPause!.complete(false) + ..pausingCompleter.future.then((_) => instance.requestResume!()); + } + return false; } /// Whether the ongoing foreground download is currently paused after a call /// to [pause] (and prior to [resume]) /// - /// {@macro fmtc.bulkDownload.numInstances} - /// /// Also returns `false` if there is no ongoing download. + /// + /// --- + /// + /// {@macro fmtc.bulkDownload.numInstances} bool isPaused({Object instanceId = 0}) => DownloadInstance.get(instanceId)?.isPaused ?? false; } From 36dd576fc5ab2dfa4254ea48436567c45dfc50c8 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 4 Jan 2025 01:14:51 +0000 Subject: [PATCH 86/97] Fixed bug where explicitly disabled stores would not be excluded from reading whilst browse caching Improved example app capabilities (added ability to explictily disable stores when neccessary) --- .../main/map_view/components/attribution.dart | 82 ++++++++++ .../src/screens/main/map_view/map_view.dart | 52 +++--- ...lumn_headers_and_inheritable_settings.dart | 151 ++++++++++-------- .../components/export_stores/button.dart | 6 +- .../example_app_limitations_text.dart | 5 + .../components/new_store_button.dart | 6 +- .../stores_list/components/no_stores.dart | 6 +- .../browse_store_strategy_selector.dart | 94 +++++++++-- .../checkbox.dart | 4 +- .../dropdown.dart | 38 +++-- .../tiles/store_tile/store_tile.dart | 39 ----- .../components/tiles/unspecified_tile.dart | 4 +- .../src/shared/state/general_provider.dart | 3 + .../impls/objectbox/backend/internal.dart | 22 ++- .../backend/internal_workers/shared.dart | 22 +++ .../internal_workers/standard/cmd_type.dart | 3 +- .../internal_workers/standard/worker.dart | 109 +++++++------ .../backend/interfaces/backend/internal.dart | 41 +++-- .../image_provider/internal_tile_browser.dart | 14 +- .../tile_provider/tile_provider.dart | 49 +++--- 20 files changed, 478 insertions(+), 272 deletions(-) create mode 100644 example/lib/src/screens/main/map_view/components/attribution.dart create mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/example_app_limitations_text.dart diff --git a/example/lib/src/screens/main/map_view/components/attribution.dart b/example/lib/src/screens/main/map_view/components/attribution.dart new file mode 100644 index 00000000..398e1e69 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/attribution.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; + +import '../map_view.dart'; + +class Attribution extends StatelessWidget { + const Attribution({ + super.key, + required this.urlTemplate, + required this.mode, + required this.stores, + required this.otherStoresStrategy, + }); + + final String urlTemplate; + final MapViewMode mode; + final Map stores; + final BrowseStoreStrategy? otherStoresStrategy; + + @override + Widget build(BuildContext context) => RichAttributionWidget( + alignment: AttributionAlignment.bottomLeft, + popupInitialDisplayDuration: const Duration(seconds: 3), + popupBorderRadius: BorderRadius.circular(12), + attributions: [ + TextSourceAttribution(Uri.parse(urlTemplate).host), + const TextSourceAttribution( + 'For demonstration purposes only', + prependCopyright: false, + textStyle: TextStyle(fontWeight: FontWeight.bold), + ), + const TextSourceAttribution( + 'Offline mapping made with FMTC', + prependCopyright: false, + textStyle: TextStyle(fontStyle: FontStyle.italic), + ), + LogoSourceAttribution( + mode == MapViewMode.standard + ? const Icon(Icons.bug_report) + : const SizedBox.shrink(), + tooltip: 'Show resolved store configuration', + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog.adaptive( + title: const Text('Resolved store configuration'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + stores.entries.isEmpty + ? 'No stores set explicitly' + : stores.entries + .map( + (e) => '${e.key}: ${e.value ?? 'Explicitly ' + 'disabled'}', + ) + .join('\n'), + ), + Text( + otherStoresStrategy == null + ? 'No other stores in use' + : 'All unspecified stores: $otherStoresStrategy', + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Understood'), + ), + ], + ), + ), + ), + LogoSourceAttribution( + Image.asset('assets/icons/ProjectIcon.png'), + tooltip: 'flutter_map_tile_caching', + ), + ], + ); +} 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 bce63ed8..a2bfe56b 100644 --- a/example/lib/src/screens/main/map_view/map_view.dart +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -18,6 +18,7 @@ import '../../../shared/state/general_provider.dart'; import '../../../shared/state/region_selection_provider.dart'; import '../../../shared/state/selected_tab_state.dart'; import 'components/additional_overlay/additional_overlay.dart'; +import 'components/attribution.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'; @@ -264,6 +265,9 @@ class _MapViewState extends State with TickerProviderStateMixin { builder: (context, provider, _) { final urlTemplate = provider.urlTemplate; + final otherStoresStrategy = provider.currentStores['(unspecified)'] + ?.toBrowseStoreStrategy(); + final compiledStoreNames = Map.fromEntries([ ...stores.entries.where((e) => e.value == urlTemplate).map((e) { @@ -276,37 +280,31 @@ class _MapViewState extends State with TickerProviderStateMixin { if (behaviour == null) return null; return MapEntry(e.key, behaviour); }).nonNulls, - ...stores.entries - .where((e) => e.value != urlTemplate) - .map((e) => MapEntry(e.key, null)), + ...stores.entries.where( + (e) { + if (e.value != urlTemplate) return true; + + final internalBehaviour = provider.currentStores[e.key]; + final behaviour = internalBehaviour == null + ? provider.inheritableBrowseStoreStrategy + : internalBehaviour.toBrowseStoreStrategy( + provider.inheritableBrowseStoreStrategy, + ); + + return provider.explicitlyExcludedStores.contains(e.key) && + behaviour == null && + otherStoresStrategy != null; + }, + ).map((e) => MapEntry(e.key, null)), ]); - final attribution = RichAttributionWidget( - alignment: AttributionAlignment.bottomLeft, - popupInitialDisplayDuration: const Duration(seconds: 3), - popupBorderRadius: BorderRadius.circular(12), - attributions: [ - TextSourceAttribution(Uri.parse(urlTemplate).host), - const TextSourceAttribution( - 'For demonstration purposes only', - prependCopyright: false, - textStyle: TextStyle(fontWeight: FontWeight.bold), - ), - const TextSourceAttribution( - 'Offline mapping made with FMTC', - prependCopyright: false, - textStyle: TextStyle(fontStyle: FontStyle.italic), - ), - LogoSourceAttribution( - Image.asset('assets/icons/ProjectIcon.png'), - tooltip: 'flutter_map_tile_caching', - ), - ], + final attribution = Attribution( + urlTemplate: urlTemplate, + mode: widget.mode, + stores: compiledStoreNames, + otherStoresStrategy: otherStoresStrategy, ); - final otherStoresStrategy = provider.currentStores['(unspecified)'] - ?.toBrowseStoreStrategy(); - final tileLayer = TileLayer( urlTemplate: urlTemplate, userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart index 33be3ce8..a84dcec0 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart @@ -66,74 +66,93 @@ class ColumnHeadersAndInheritableSettings extends StatelessWidget { ), ), const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Selector( - selector: (context, provider) => - provider.inheritableBrowseStoreStrategy, - builder: (context, currentBehaviour, child) { - if (useCompactLayout) { - return Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: DropdownButton( - items: [null] - .followedBy(BrowseStoreStrategy.values) - .map( - (e) => DropdownMenuItem( - value: e, - alignment: Alignment.center, - child: switch (e) { - null => const Icon( - Icons.disabled_by_default_rounded, - ), - BrowseStoreStrategy.read => - const Icon(Icons.visibility), - BrowseStoreStrategy.readUpdate => - const Icon(Icons.edit), - BrowseStoreStrategy.readUpdateCreate => - const Icon(Icons.add), - }, - ), - ) - .toList(), - value: currentBehaviour, - onChanged: (v) => context - .read() - .inheritableBrowseStoreStrategy = v, - ), - ), - ); - } - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: BrowseStoreStrategy.values.map( - (e) { - final value = currentBehaviour == e - ? true - : InternalBrowseStoreStrategy.priority - .indexOf(currentBehaviour) < - InternalBrowseStoreStrategy.priority - .indexOf(e) - ? false - : null; - - return Checkbox.adaptive( - value: value, - onChanged: (v) => context + Row( + children: [ + const SizedBox(width: 20), + Tooltip( + message: 'These inheritance options are tracked manually by\n' + 'the app and not FMTC. This enables both inheritance\n' + 'and "All unspecified" (which uses `otherStoresStrategy`\n' + 'in FMTC) to be represented in the example app. Tap\n' + 'the debug icon in the map attribution to see how the\n' + 'store configuration is resolved and passed to FMTC.', + textAlign: TextAlign.center, + child: Icon( + Icons.help_outline, + color: Colors.black.withAlpha(255 ~/ 3), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Selector( + selector: (context, provider) => + provider.inheritableBrowseStoreStrategy, + builder: (context, currentBehaviour, child) { + if (useCompactLayout) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: DropdownButton( + items: [null] + .followedBy(BrowseStoreStrategy.values) + .map( + (e) => DropdownMenuItem( + value: e, + alignment: Alignment.center, + child: switch (e) { + null => const Icon( + Icons.disabled_by_default_rounded, + ), + BrowseStoreStrategy.read => + const Icon(Icons.visibility), + BrowseStoreStrategy.readUpdate => + const Icon(Icons.edit), + BrowseStoreStrategy.readUpdateCreate => + const Icon(Icons.add), + }, + ), + ) + .toList(), + value: currentBehaviour, + onChanged: (v) => context .read() - .inheritableBrowseStoreStrategy = - v == null ? null : e, - tristate: true, - materialTapTargetSize: MaterialTapTargetSize.padded, - visualDensity: VisualDensity.comfortable, + .inheritableBrowseStoreStrategy = v, + ), + ), ); - }, - ).toList(growable: false), - ); - }, - ), + } + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: BrowseStoreStrategy.values.map( + (e) { + final value = currentBehaviour == e + ? true + : InternalBrowseStoreStrategy.priority + .indexOf(currentBehaviour) < + InternalBrowseStoreStrategy.priority + .indexOf(e) + ? false + : null; + + return Checkbox.adaptive( + value: value, + onChanged: (v) => context + .read() + .inheritableBrowseStoreStrategy = + v == null ? null : e, + tristate: true, + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.comfortable, + ); + }, + ).toList(growable: false), + ); + }, + ), + ), + ], ), const Divider(height: 8, indent: 12, endIndent: 12), ], diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart index 42a53465..533899d2 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart @@ -10,6 +10,7 @@ import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import '../../state/export_selection_provider.dart'; +import 'example_app_limitations_text.dart'; part 'name_input_dialog.dart'; part 'progress_dialog.dart'; @@ -48,10 +49,7 @@ class ExportStoresButton extends StatelessWidget { ), const SizedBox(height: 24), Text( - 'Within the example app, for simplicity, each store contains ' - 'tiles from a single URL template. Additionally, only one tile ' - 'layer with a single URL template can be used at any one time. ' - 'These are not limitations with FMTC.', + exampleAppLimitationsText, textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall, ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/example_app_limitations_text.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/example_app_limitations_text.dart new file mode 100644 index 00000000..46832528 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/example_app_limitations_text.dart @@ -0,0 +1,5 @@ +const exampleAppLimitationsText = + 'There are some limitations to the example app which do not exist in FMTC, ' + 'because it is difficult to express in this UI design.\nEach store only ' + 'contains tiles from a single URL template. Only a single tile layer is ' + 'used/available (only a single URL template can be used at any one time).'; diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart index d926a38d..e09f1bea 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../../../../import/import.dart'; import '../../../../../../../store_editor/store_editor.dart'; +import 'export_stores/example_app_limitations_text.dart'; class NewStoreButton extends StatelessWidget { const NewStoreButton({super.key}); @@ -36,10 +37,7 @@ class NewStoreButton extends StatelessWidget { ), const SizedBox(height: 24), Text( - 'Within the example app, for simplicity, each store contains ' - 'tiles from a single URL template. Additionally, only one tile ' - 'layer with a single URL template can be used at any one time. ' - 'These are not limitations with FMTC.', + exampleAppLimitationsText, textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall, ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart index c6544fef..9d2ec96e 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../../../../import/import.dart'; import '../../../../../../../store_editor/store_editor.dart'; +import 'export_stores/example_app_limitations_text.dart'; class NoStores extends StatelessWidget { const NoStores({super.key}); @@ -50,10 +51,7 @@ class NoStores extends StatelessWidget { ), const SizedBox(height: 32), Text( - 'Within the example app, for simplicity, each store contains ' - 'tiles from a single URL template. Additionally, only one ' - 'tile layer with a single URL template can be used at any ' - 'one time. These are not limitations with FMTC.', + exampleAppLimitationsText, textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall, ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart index 6ef7854a..0b6599b5 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart @@ -23,6 +23,7 @@ class BrowseStoreStrategySelector extends StatelessWidget { final bool useCompactLayout; static const _unspecifiedSelectorColor = Colors.pinkAccent; + static const _unspecifiedSelectorExcludedColor = Colors.purple; @override Widget build(BuildContext context) { @@ -30,10 +31,11 @@ class BrowseStoreStrategySelector extends StatelessWidget { context.select( (provider) => provider.currentStores[storeName], ); - final unspecifiedStrategy = - context.select( - (provider) => provider.currentStores['(unspecified)'], - ); + final unspecifiedStrategy = context + .select( + (provider) => provider.currentStores['(unspecified)'], + ) + ?.toBrowseStoreStrategy(); final inheritableStrategy = inheritable ? context.select( (provider) => provider.inheritableBrowseStoreStrategy, @@ -44,9 +46,17 @@ class BrowseStoreStrategySelector extends StatelessWidget { ? inheritableStrategy : currentStrategy.toBrowseStoreStrategy(inheritableStrategy); final isUsingUnselectedStrategy = resolvedCurrentStrategy == null && - unspecifiedStrategy != InternalBrowseStoreStrategy.disable && + unspecifiedStrategy != null && enabled; + final showExplicitExcludeCheckbox = + resolvedCurrentStrategy == null && isUsingUnselectedStrategy; + + final isExplicitlyExcluded = showExplicitExcludeCheckbox && + context.select( + (provider) => provider.explicitlyExcludedStores.contains(storeName), + ); + // Parameter meaning obvious from context, also callback // ignore: avoid_positional_boolean_parameters void changedInheritCheckbox(bool? value) { @@ -61,10 +71,66 @@ class BrowseStoreStrategySelector extends StatelessWidget { ..changedCurrentStores(); } + // Parameter meaning obvious from context, also callback + // ignore: avoid_positional_boolean_parameters + void changedExplicitlyExcludeCheckbox(bool? value) { + final provider = context.read(); + + if (value!) { + provider.explicitlyExcludedStores.add(storeName); + } else { + provider.explicitlyExcludedStores.remove(storeName); + } + + provider.changedExplicitlyExcludedStores(); + } + return Row( mainAxisSize: MainAxisSize.min, children: [ if (inheritable) ...[ + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceDim, + borderRadius: BorderRadius.circular(99), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) => SizeTransition( + sizeFactor: Tween(begin: 0, end: 1).animate(animation), + axis: Axis.horizontal, + axisAlignment: 1, + child: SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + child: showExplicitExcludeCheckbox + ? Tooltip( + message: 'Explicitly disable', + child: Padding( + padding: const EdgeInsets.all(4) + + const EdgeInsets.symmetric(horizontal: 4), + child: Row( + spacing: 6, + children: [ + const Icon(Icons.disabled_by_default_rounded), + Checkbox.adaptive( + value: isExplicitlyExcluded, + onChanged: changedExplicitlyExcludeCheckbox, + activeColor: BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor, + ), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + ), Checkbox.adaptive( value: currentStrategy == InternalBrowseStoreStrategy.inherit || currentStrategy == null, @@ -80,6 +146,7 @@ class BrowseStoreStrategySelector extends StatelessWidget { currentStrategy: resolvedCurrentStrategy, enabled: enabled, isUnspecifiedSelector: storeName == '(unspecified)', + isExplicitlyExcluded: isExplicitlyExcluded, ) else Stack( @@ -87,18 +154,21 @@ class BrowseStoreStrategySelector extends StatelessWidget { Transform.translate( offset: const Offset(2, 0), child: AnimatedContainer( - duration: const Duration(milliseconds: 100), + duration: const Duration(milliseconds: 150), decoration: BoxDecoration( - color: BrowseStoreStrategySelector._unspecifiedSelectorColor - .withValues(alpha: 0.75), + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + .withAlpha(255 ~/ 2) + : BrowseStoreStrategySelector._unspecifiedSelectorColor + .withValues(alpha: 0.75), borderRadius: BorderRadius.circular(99), ), width: isUsingUnselectedStrategy ? switch (unspecifiedStrategy) { - InternalBrowseStoreStrategy.read => 40, - InternalBrowseStoreStrategy.readUpdate => 85, - InternalBrowseStoreStrategy.readUpdateCreate => 128, - _ => 0, + BrowseStoreStrategy.read => 40, + BrowseStoreStrategy.readUpdate => 85, + BrowseStoreStrategy.readUpdateCreate => 128, } : 0, ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart index 20fe60cb..2a1ad6af 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart @@ -57,9 +57,9 @@ class _BrowseStoreStrategySelectorCheckbox extends StatelessWidget { activeColor: isUnspecifiedSelector ? BrowseStoreStrategySelector._unspecifiedSelectorColor : null, - fillColor: WidgetStateProperty.resolveWith((states) { + /*fillColor: WidgetStateProperty.resolveWith((states) { if (states.isEmpty) return Theme.of(context).colorScheme.surface; return null; - }), + }),*/ ); } diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart index ce5c6392..55b6fbad 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart @@ -6,12 +6,14 @@ class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { required this.currentStrategy, required this.enabled, required this.isUnspecifiedSelector, + required this.isExplicitlyExcluded, }); final String storeName; final BrowseStoreStrategy? currentStrategy; final bool enabled; final bool isUnspecifiedSelector; + final bool isExplicitlyExcluded; @override Widget build(BuildContext context) => Padding( @@ -36,25 +38,37 @@ class _BrowseStoreStrategySelectorDropdown extends StatelessWidget { .select( (provider) => provider.currentStores['(unspecified)'], )) { - InternalBrowseStoreStrategy.read => const Icon( + InternalBrowseStoreStrategy.read => Icon( Icons.visibility, - color: BrowseStoreStrategySelector - ._unspecifiedSelectorColor, + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + : BrowseStoreStrategySelector + ._unspecifiedSelectorColor, ), - InternalBrowseStoreStrategy.readUpdate => const Icon( + InternalBrowseStoreStrategy.readUpdate => Icon( Icons.edit, - color: BrowseStoreStrategySelector - ._unspecifiedSelectorColor, + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + : BrowseStoreStrategySelector + ._unspecifiedSelectorColor, ), - InternalBrowseStoreStrategy.readUpdateCreate => const Icon( + InternalBrowseStoreStrategy.readUpdateCreate => Icon( Icons.add, - color: BrowseStoreStrategySelector - ._unspecifiedSelectorColor, + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + : BrowseStoreStrategySelector + ._unspecifiedSelectorColor, ), - _ => const Icon( + _ => Icon( Icons.disabled_by_default_rounded, - color: BrowseStoreStrategySelector - ._unspecifiedSelectorColor, + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + : BrowseStoreStrategySelector + ._unspecifiedSelectorColor, ), }, BrowseStoreStrategy.read => diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart index b146e198..247d2222 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart @@ -93,45 +93,6 @@ class _StoreTileState extends State { visualDensity: widget.useCompactLayout ? VisualDensity.compact : null, ), - /*FutureBuilder( - future: widget.stats, - builder: (context, statsSnapshot) { - if (statsSnapshot.data?.length == 0) { - return IconButton( - onPressed: _deleteStore, - icon: const Icon( - Icons.delete_forever, - color: Colors.red, - ), - visualDensity: widget.useCompactLayout - ? VisualDensity.compact - : null, - ); - } - - if (_toolsEmptyLoading) { - return const IconButton( - onPressed: null, - icon: SizedBox.square( - dimension: 18, - child: Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 3, - ), - ), - ), - ); - } - - return IconButton( - onPressed: _emptyStore, - icon: const Icon(Icons.delete), - visualDensity: widget.useCompactLayout - ? VisualDensity.compact - : null, - ); - }, - ),*/ const SizedBox(width: 4), ]; diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart index 006d88bb..0b8a2018 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart @@ -37,7 +37,7 @@ class _UnspecifiedTileState extends State { color: Colors.transparent, child: ListTile( title: const Text( - 'All disabled', + 'All unspecified', maxLines: 2, overflow: TextOverflow.fade, style: TextStyle(fontStyle: FontStyle.italic), @@ -65,9 +65,9 @@ class _UnspecifiedTileState extends State { borderRadius: BorderRadius.circular(99), ), child: Row( + spacing: 4, children: [ const Icon(Icons.last_page), - const SizedBox(width: 4), Switch.adaptive( value: !isAllUnselectedDisabled && context.select( diff --git a/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart index cb356cfb..7b57cb66 100644 --- a/example/lib/src/shared/state/general_provider.dart +++ b/example/lib/src/shared/state/general_provider.dart @@ -44,6 +44,9 @@ class GeneralProvider extends ChangeNotifier { final Map currentStores = {}; void changedCurrentStores() => notifyListeners(); + final Set explicitlyExcludedStores = {}; + void changedExplicitlyExcludedStores() => notifyListeners(); + String _urlTemplate = sharedPrefs.getString(SharedPrefsKeys.urlTemplate.name) ?? (() { diff --git a/lib/src/backend/impls/objectbox/backend/internal.dart b/lib/src/backend/impls/objectbox/backend/internal.dart index f7b90b14..b6e63090 100644 --- a/lib/src/backend/impls/objectbox/backend/internal.dart +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -376,7 +376,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { @override Future tileExists({ required String url, - List? storeNames, + required ({bool includeOrExclude, List storeNames}) storeNames, }) async => (await _sendCmdOneShot( type: _CmdType.tileExists, @@ -391,7 +391,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { List allStoreNames, })> readTile({ required String url, - List? storeNames, + required ({bool includeOrExclude, List storeNames}) storeNames, }) async { final res = (await _sendCmdOneShot( type: _CmdType.readTile, @@ -441,13 +441,21 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ))!['wasOrphan']; @override - Future registerHitOrMiss({ - required List? storeNames, - required bool hit, + Future incrementStoreHits({ + required List storeNames, + }) => + _sendCmdOneShot( + type: _CmdType.incrementStoreHits, + args: {'storeNames': storeNames}, + ); + + @override + Future incrementStoreMisses({ + required ({bool includeOrExclude, List storeNames}) storeNames, }) => _sendCmdOneShot( - type: _CmdType.registerHitOrMiss, - args: {'storeNames': storeNames, 'hit': hit}, + type: _CmdType.incrementStoreMisses, + args: {'storeNames': storeNames}, ); @override diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart index 053b05e0..eae17e56 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart @@ -3,6 +3,28 @@ part of '../backend.dart'; +List _resolveReadableStoresFormat( + ({bool includeOrExclude, List storeNames}) readableStores, { + required Store root, +}) { + final availableStoreNames = + root.box().getAll().map((e) => e.name); + + if (!readableStores.includeOrExclude) { + return availableStoreNames + .whereNot((e) => readableStores.storeNames.contains(e)) + .toList(growable: false); + } + + for (final storeName in readableStores.storeNames) { + if (!availableStoreNames.contains(storeName)) { + throw StoreNotExists(storeName: storeName); + } + } + + return readableStores.storeNames; +} + Map _sharedWriteSingleTile({ required Store root, required List storeNames, diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart index f2f4b36a..b64b53e2 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart @@ -25,7 +25,8 @@ enum _CmdType { readLatestTile, writeTile, deleteTile, - registerHitOrMiss, + incrementStoreHits, + incrementStoreMisses, removeOldestTilesAboveLimit, removeTilesOlderThan, readMetadata, diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index fa1a0c22..c63b22de 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -424,17 +424,18 @@ Future _worker( tilesQuery.close(); case _CmdType.tileExists: final url = cmd.args['url']! as String; - final storeNames = cmd.args['storeNames']! as List?; - - final stores = root.box(); + final storeNames = cmd.args['storeNames']! as ({ + bool includeOrExclude, + List storeNames, + }); - final queryPart = stores.query(ObjectBoxTile_.url.equals(url)); - final query = storeNames == null - ? queryPart.build() - : (queryPart + final query = + (root.box().query(ObjectBoxTile_.url.equals(url)) ..linkMany( ObjectBoxTile_.stores, - ObjectBoxStore_.name.oneOf(storeNames), + ObjectBoxStore_.name.oneOf( + _resolveReadableStoresFormat(storeNames, root: root), + ), )) .build(); @@ -443,18 +444,19 @@ Future _worker( query.close(); case _CmdType.readTile: final url = cmd.args['url']! as String; - final storeNames = cmd.args['storeNames'] as List?; + final storeNames = cmd.args['storeNames'] as ({ + bool includeOrExclude, + List storeNames, + }); - final stores = root.box(); - final specifiedStores = storeNames?.isNotEmpty ?? false; + final resolvedStores = + _resolveReadableStoresFormat(storeNames, root: root); - final queryPart = stores.query(ObjectBoxTile_.url.equals(url)); - final query = !specifiedStores - ? queryPart.build() - : (queryPart + final query = + (root.box().query(ObjectBoxTile_.url.equals(url)) ..linkMany( ObjectBoxTile_.stores, - ObjectBoxStore_.name.oneOf(storeNames!), + ObjectBoxStore_.name.oneOf(resolvedStores), )) .build(); @@ -471,19 +473,18 @@ Future _worker( }, ); } else { - final tileStores = tile.stores.map((s) => s.name); - final listTileStores = tileStores.toList(growable: false); + final listTileStores = + tile.stores.map((s) => s.name).toList(growable: false); + final intersectedStoreNames = listTileStores + .where(resolvedStores.contains) + .toList(growable: false); sendRes( id: cmd.id, data: { 'tile': tile, 'allStoreNames': listTileStores, - 'intersectedStoreNames': !specifiedStores - ? listTileStores - : SplayTreeSet.from(tileStores) - .intersection(SplayTreeSet.from(storeNames!)) - .toList(growable: false), + 'intersectedStoreNames': intersectedStoreNames, }, ); } @@ -541,40 +542,56 @@ Future _worker( storesQuery.close(); tilesQuery.close(); - case _CmdType.registerHitOrMiss: - final storeNames = cmd.args['storeNames'] as List?; - final hit = cmd.args['hit']! as bool; + case _CmdType.incrementStoreHits: + final storeNames = cmd.args['storeNames'] as List; final storesBox = root.box(); - final specifiedStores = storeNames?.isNotEmpty ?? false; - late final Query query; - if (specifiedStores) { - query = - storesBox.query(ObjectBoxStore_.name.oneOf(storeNames!)).build(); - } + final query = + storesBox.query(ObjectBoxStore_.name.oneOf(storeNames)).build(); root.runInTransaction( TxMode.write, () { - final stores = specifiedStores ? query.find() : storesBox.getAll(); - if (specifiedStores) { - if (stores.length != storeNames!.length) { - return StoreNotExists( - storeName: - storeNames.toSet().difference(stores.toSet()).join('; '), - ); - } + final stores = query.find(); + + if (stores.length != storeNames.length) { + return StoreNotExists( + storeName: storeNames + .toSet() + .difference(stores.map((s) => s.name).toSet()) + .join('; '), + ); + } - query.close(); + for (final store in stores) { + storesBox.put(store..hits += 1); } + }, + ); + + sendRes(id: cmd.id); + case _CmdType.incrementStoreMisses: + final storeNames = cmd.args['storeNames'] as ({ + bool includeOrExclude, + List storeNames, + }); + + final resolvedStoreNames = + _resolveReadableStoresFormat(storeNames, root: root); + + final storesBox = root.box(); + final query = storesBox + .query(ObjectBoxStore_.name.oneOf(resolvedStoreNames)) + .build(); + + root.runInTransaction( + TxMode.write, + () { + final stores = query.find(); for (final store in stores) { - storesBox.put( - store - ..hits += hit ? 1 : 0 - ..misses += hit ? 0 : 1, - ); + storesBox.put(store..misses += 1); } }, ); diff --git a/lib/src/backend/interfaces/backend/internal.dart b/lib/src/backend/interfaces/backend/internal.dart index c9c26910..904400e6 100644 --- a/lib/src/backend/interfaces/backend/internal.dart +++ b/lib/src/backend/interfaces/backend/internal.dart @@ -157,22 +157,26 @@ abstract interface class FMTCBackendInternal required String storeName, }); - /// Check whether the specified tile exists in any of the specified stores (or - /// any store is [storeNames] is `null`) + /// Check whether the specified tile exists in any of the specified stores + /// + /// {@template fmtc.backend._readableStoresFormat} + /// [storeNames] uses the "readable stores" format. If `includeOrExclude` is + /// `true`, the operation will apply to all stores included only in + /// `storeNames`. Otherwise, the operation will apply to all existing stores + /// NOT included in `storeNames`. This should reflect the settings of an + /// [FMTCTileProvider] (see [FMTCTileProvider._compileReadableStores]). + /// {@endtemplate} Future tileExists({ required String url, - List? storeNames, + required ({List storeNames, bool includeOrExclude}) storeNames, }); - /// Retrieve a raw `tile` from any of the specified [storeNames] (or all store - /// names if `null` or empty) by the specified URL + /// Retrieve a raw `tile` by URL from any of the specified stores /// - /// Returns the list of store names the tile belongs to - `allStoreNames` - - /// and were present in [storeNames] if specified - `intersectedStoreNames`. + /// {@macro fmtc.backend._readableStoresFormat} /// - /// If [storeNames] is `null` or empty, tiles may be retrieved from any store - /// (which may be slower depending on the size of the root, as queries may - /// be unconstrained). + /// Returns the list of store names the tile belongs to (`allStoreNames`), + /// and were present in the resolved stores (`intersectedStoreNames`). /// /// `intersectedStoreNames` & `allStoreNames` will be empty if `tile` is /// `null`. @@ -183,7 +187,7 @@ abstract interface class FMTCBackendInternal List allStoreNames, })> readTile({ required String url, - List? storeNames, + required ({List storeNames, bool includeOrExclude}) storeNames, }); /// {@template fmtc.backend.readLatestTile} @@ -223,11 +227,16 @@ abstract interface class FMTCBackendInternal required String url, }); - /// Register a cache hit or miss on the specified stores, or all stores if - /// null or empty - Future registerHitOrMiss({ - required List? storeNames, - required bool hit, + /// Add a cache hit to all specified stores + Future incrementStoreHits({ + required List storeNames, + }); + + /// Add a cache miss to all specified stores + /// + /// {@macro fmtc.backend._readableStoresFormat} + Future incrementStoreMisses({ + required ({List storeNames, bool includeOrExclude}) storeNames, }); /// Remove tiles in excess of the specified limit in each specified store, diff --git a/lib/src/providers/image_provider/internal_tile_browser.dart b/lib/src/providers/image_provider/internal_tile_browser.dart index db91667a..26a8dd05 100644 --- a/lib/src/providers/image_provider/internal_tile_browser.dart +++ b/lib/src/providers/image_provider/internal_tile_browser.dart @@ -10,21 +10,20 @@ Future _internalTileBrowser({ required bool requireValidImage, required _TLIRConstructor? currentTLIR, }) async { + late final compiledReadableStores = provider._compileReadableStores(); + void registerHit(List storeNames) { currentTLIR?.hitOrMiss = true; if (provider.recordHitsAndMisses) { - FMTCBackendAccess.internal - .registerHitOrMiss(storeNames: storeNames, hit: true); + FMTCBackendAccess.internal.incrementStoreHits(storeNames: storeNames); } } void registerMiss() { currentTLIR?.hitOrMiss = false; if (provider.recordHitsAndMisses) { - FMTCBackendAccess.internal.registerHitOrMiss( - storeNames: provider._getSpecifiedStoresOrNull(), - hit: false, - ); + FMTCBackendAccess.internal + .incrementStoreMisses(storeNames: compiledReadableStores); } } @@ -43,7 +42,7 @@ Future _internalTileBrowser({ allStoreNames: allExistingStores, ) = await FMTCBackendAccess.internal.readTile( url: matcherUrl, - storeNames: provider._getSpecifiedStoresOrNull(), + storeNames: compiledReadableStores, ); currentTLIR?.cacheFetchDuration = @@ -202,6 +201,7 @@ Future _internalTileBrowser({ } } + // TODO: This isn't resolving properly! // Find the stores that need to have this tile written to, depending on // their read/write settings // At this point, we've downloaded the tile anyway, so we might as well diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index e75a7a7e..d737ce46 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -369,7 +369,7 @@ class FMTCTileProvider extends TileProvider { }) { final networkUrl = getTileUrl(coords, options); return FMTCBackendAccess.internal.tileExists( - storeNames: _getSpecifiedStoresOrNull(), + storeNames: _compileReadableStores(), url: urlTransformer?.call(networkUrl) ?? networkUrl, ); } @@ -411,13 +411,18 @@ class FMTCTileProvider extends TileProvider { return mutableUrl; } - // TODO: This does not work correctly. Needs a complex system like writing. - List? _getSpecifiedStoresOrNull() => otherStoresStrategy != null - ? null - : /*stores.keys.toList()*/ stores.entries - .where((e) => e.value != null) - .map((e) => e.key) - .toList(); + /// Compile the [FMTCTileProvider.stores] & + /// [FMTCTileProvider.otherStoresStrategy] into a format which can be resolved + /// by the backend once all available stores are known + ({List storeNames, bool includeOrExclude}) _compileReadableStores() { + final excludeOrInclude = otherStoresStrategy != null; + final storeNames = (excludeOrInclude + ? stores.entries.where((e) => e.value == null) + : stores.entries.where((e) => e.value != null)) + .map((e) => e.key) + .toList(growable: false); + return (storeNames: storeNames, includeOrExclude: !excludeOrInclude); + } @override bool operator ==(Object other) => @@ -436,19 +441,17 @@ class FMTCTileProvider extends TileProvider { mapEquals(other.headers, headers)); @override - int get hashCode => Object.hashAllUnordered( - [ - otherStoresStrategy, - loadingStrategy, - useOtherStoresAsFallbackOnly, - recordHitsAndMisses, - cachedValidDuration, - urlTransformer, - errorHandler, - tileLoadingInterceptor, - httpClient, - ...stores.entries.map((e) => (e.key, e.value)), - ...headers.entries.map((e) => (e.key, e.value)), - ], - ); + int get hashCode => Object.hashAllUnordered([ + otherStoresStrategy, + loadingStrategy, + useOtherStoresAsFallbackOnly, + recordHitsAndMisses, + cachedValidDuration, + urlTransformer, + errorHandler, + tileLoadingInterceptor, + httpClient, + ...stores.entries.map((e) => (e.key, e.value)), + ...headers.entries.map((e) => (e.key, e.value)), + ]); } From 476240d83ec01ea46b8c7f12e7c522751cb275fc Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 4 Jan 2025 22:59:48 +0000 Subject: [PATCH 87/97] Fixed bug where tile would be written without stores & root stats would be incorrectly updated when resolved store set is empty --- .../impls/objectbox/backend/internal_workers/shared.dart | 2 ++ lib/src/providers/image_provider/internal_tile_browser.dart | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart index eae17e56..eb2fe618 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart @@ -53,6 +53,8 @@ Map _sharedWriteSingleTile({ ), ]; + if (compiledStoreNames.isEmpty) return const {}; + final tilesQuery = tiles.query(ObjectBoxTile_.url.equals(url)).build(); final storeQuery = storesBox.query(ObjectBoxStore_.name.oneOf(compiledStoreNames)).build(); diff --git a/lib/src/providers/image_provider/internal_tile_browser.dart b/lib/src/providers/image_provider/internal_tile_browser.dart index 26a8dd05..42665114 100644 --- a/lib/src/providers/image_provider/internal_tile_browser.dart +++ b/lib/src/providers/image_provider/internal_tile_browser.dart @@ -201,7 +201,6 @@ Future _internalTileBrowser({ } } - // TODO: This isn't resolving properly! // Find the stores that need to have this tile written to, depending on // their read/write settings // At this point, we've downloaded the tile anyway, so we might as well From 2b763f391a47c52a447978bf1c4b3f36e0a2ebce Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 5 Jan 2025 16:49:06 +0000 Subject: [PATCH 88/97] Improved example app --- .../components/new_store_button.dart | 83 +++- .../stores_list/components/no_stores.dart | 14 +- .../custom_single_slidable_action.dart | 225 +++++++++++ .../store_empty_deletion_dialog.dart | 141 ------- .../tiles/store_tile/components/trailing.dart | 87 ----- .../tiles/store_tile/store_tile.dart | 359 +++++++++++------- .../components/stores_list/stores_list.dart | 21 +- .../screens/store_editor/store_editor.dart | 4 +- example/pubspec.yaml | 1 + 9 files changed, 544 insertions(+), 391 deletions(-) create mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/custom_single_slidable_action.dart delete mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/store_empty_deletion_dialog.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart index e09f1bea..be87321a 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart @@ -4,20 +4,35 @@ import '../../../../../../../import/import.dart'; import '../../../../../../../store_editor/store_editor.dart'; import 'export_stores/example_app_limitations_text.dart'; -class NewStoreButton extends StatelessWidget { +class NewStoreButton extends StatefulWidget { const NewStoreButton({super.key}); + @override + State createState() => _NewStoreButtonState(); +} + +class _NewStoreButtonState extends State { + bool _showingImportExportButtons = false; + @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Column( children: [ - IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: SizedBox( - height: double.infinity, + Row( + children: [ + Expanded( + child: AnimatedCrossFade( + crossFadeState: _showingImportExportButtons + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 250), + firstCurve: Curves.easeInOut, + secondCurve: Curves.easeInOut, + sizeCurve: Curves.easeInOut, + firstChild: SizedBox( + height: 38, + width: double.infinity, child: FilledButton.tonalIcon( label: const Text('Create new store'), icon: const Icon(Icons.create_new_folder), @@ -25,15 +40,55 @@ class NewStoreButton extends StatelessWidget { .pushNamed(StoreEditorPopup.route), ), ), + secondChild: Row( + spacing: 8, + children: [ + Expanded( + child: SizedBox( + height: 38, + child: OutlinedButton.icon( + onPressed: () => ImportPopup.start(context), + icon: const Icon(Icons.file_open), + label: const Text('Import'), + ), + ), + ), + Expanded( + child: SizedBox( + height: 38, + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.send_and_archive), + label: const Text('Export'), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + AnimatedCrossFade( + crossFadeState: _showingImportExportButtons + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 250), + firstCurve: Curves.easeInOut, + secondCurve: Curves.easeInOut, + sizeCurve: Curves.easeInOut, + firstChild: IconButton.outlined( + icon: const Icon(Icons.import_export), + tooltip: 'Import/Export', + onPressed: () => + setState(() => _showingImportExportButtons = true), ), - const SizedBox(width: 8), - IconButton.outlined( - icon: const Icon(Icons.file_open), - tooltip: 'Import store', - onPressed: () => ImportPopup.start(context), + secondChild: IconButton( + onPressed: () => + setState(() => _showingImportExportButtons = false), + icon: const Icon(Icons.close), ), - ], - ), + ), + ], ), const SizedBox(height: 24), Text( diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart index 9d2ec96e..7b3fe750 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart @@ -5,7 +5,12 @@ import '../../../../../../../store_editor/store_editor.dart'; import 'export_stores/example_app_limitations_text.dart'; class NoStores extends StatelessWidget { - const NoStores({super.key}); + const NoStores({ + super.key, + required this.newStoreName, + }); + + final void Function(String) newStoreName; @override Widget build(BuildContext context) => SliverFillRemaining( @@ -33,8 +38,11 @@ class NoStores extends StatelessWidget { height: 42, width: double.infinity, child: FilledButton.icon( - onPressed: () => - Navigator.of(context).pushNamed(StoreEditorPopup.route), + onPressed: () async { + final result = await Navigator.of(context) + .pushNamed(StoreEditorPopup.route); + if (result is String) newStoreName(result); + }, icon: const Icon(Icons.create_new_folder), label: const Text('Create new store'), ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/custom_single_slidable_action.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/custom_single_slidable_action.dart new file mode 100644 index 00000000..d3f5a094 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/custom_single_slidable_action.dart @@ -0,0 +1,225 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; + +class CustomSingleSlidableAction extends StatefulWidget { + const CustomSingleSlidableAction({ + required super.key, + required this.unconfirmedIcon, + required this.confirmedIcon, + required this.color, + required this.alignment, + required this.dismissThreshold, + this.showLoader = false, + }); + + final IconData unconfirmedIcon; + final IconData confirmedIcon; + final Color color; + final Alignment alignment; + final double dismissThreshold; + final bool showLoader; + + @override + State createState() => + _CustomSingleSlidableActionState(); +} + +class _CustomSingleSlidableActionState extends State + with SingleTickerProviderStateMixin { + late final _inkWellKey = GlobalKey(); + + late final _animationController = AnimationController( + duration: const Duration(milliseconds: 120), + vsync: this, + ); + late final _sizeAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInQuart, + reverseCurve: Curves.easeIn, + )..addStatusListener(_autoReverser); + late final _rotationAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.elasticIn, + )..addStatusListener(_autoReverser); + void _autoReverser(AnimationStatus status) { + if (status == AnimationStatus.completed) { + _animationController.reverse(); + } + } + + final _scaleTween = Tween(begin: 1, end: 1.15); + final _rotationTween = Tween(begin: 0, end: 0.06); + + double _prevMaxWidth = 0; + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Expanded( + child: LayoutBuilder( + builder: (context, innerConstraints) { + final willAct = + innerConstraints.maxWidth >= widget.dismissThreshold; + + if (innerConstraints.maxWidth > _prevMaxWidth && + _prevMaxWidth < widget.dismissThreshold && + willAct) { + _animationController.forward(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final box = _inkWellKey.currentContext!.findRenderObject()! + as RenderBox; + final position = box.localToGlobal( + Offset( + lerpDouble( + 0, + box.size.width, + (widget.alignment.x.clamp(-1, 1) + 1) / 2, + )!, + box.size.height / 2, + ), + ); + + _inkWellKey.currentContext!.visitChildElements((element) { + assert( + element.widget.runtimeType.toString() == + '_InkResponseStateWidget' && + element is StatefulElement, + 'Child elements traversal failed', + ); + + final inkResponseState = + (element as StatefulElement).state as dynamic; + + // Shenanigans + // ignore: avoid_dynamic_calls + inkResponseState.handleTapDown( + TapDownDetails(globalPosition: position), + ); + // Shenanigans + // ignore: avoid_dynamic_calls + inkResponseState.handleLongPress(); + }); + }); + } + + _prevMaxWidth = innerConstraints.maxWidth; + + final icon = Flexible( + child: RotationTransition( + turns: _rotationTween.animate(_rotationAnimation), + child: ScaleTransition( + scale: _scaleTween.animate(_sizeAnimation), + child: SizedBox.square( + dimension: 24, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + child: willAct + ? Icon( + key: const ValueKey(1), + widget.confirmedIcon, + color: Theme.of(context).colorScheme.surface, + ) + : Icon( + key: const ValueKey(0), + widget.unconfirmedIcon, + color: Theme.of(context).colorScheme.surface, + ), + ), + ), + ), + ), + ); + + final loader = Flexible( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) => SizeTransition( + sizeFactor: + Tween(begin: 0, end: 1).animate(animation), + axis: Axis.horizontal, + fixedCrossAxisSizeFactor: 1, + child: child, + ), + layoutBuilder: (currentChild, previousChildren) => Stack( + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: widget.showLoader + ? UnconstrainedBox( + constrainedAxis: Axis.horizontal, + child: Padding( + padding: EdgeInsets.only( + left: widget.alignment.x >= 0 ? 12 : 0, + right: widget.alignment.x <= 0 ? 12 : 0, + ), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 24, + maxWidth: 24, + ), + child: AspectRatio( + aspectRatio: 1, + child: CircularProgressIndicator.adaptive( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.surface, + ), + strokeAlign: + CircularProgressIndicator.strokeAlignInside, + ), + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + ); + + return Material( + color: Colors.transparent, + child: InkWell( + key: _inkWellKey, + radius: innerConstraints.maxWidth, + splashFactory: InkSparkle.splashFactory, + canRequestFocus: false, + child: TweenAnimationBuilder( + tween: ColorTween( + begin: widget.color.withAlpha(204), + end: willAct ? widget.color : widget.color.withAlpha(204), + ), + duration: const Duration(milliseconds: 120), + curve: Curves.easeIn, + builder: (context, color, child) => Ink( + color: color, + padding: const EdgeInsets.symmetric(horizontal: 16), + height: double.infinity, + child: child, + ), + child: Opacity( + opacity: innerConstraints.maxWidth.clamp(0, 56) / 56, + child: Row( + mainAxisAlignment: widget.alignment.x > 0 + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: widget.alignment.x > 0 + ? [icon, loader] + : [loader, icon], + ), + ), + ), + ), + ); + }, + ), + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/store_empty_deletion_dialog.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/store_empty_deletion_dialog.dart deleted file mode 100644 index 58137247..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/store_empty_deletion_dialog.dart +++ /dev/null @@ -1,141 +0,0 @@ -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'; - -class StoreEmptyDeletionDialog extends StatefulWidget { - const StoreEmptyDeletionDialog({ - super.key, - required this.storeName, - }); - - final String storeName; - - @override - State createState() => - _StoreEmptyDeletionDialogState(); -} - -class _StoreEmptyDeletionDialogState extends State { - late final _recoveryRegions = FMTCRoot.recovery.recoverableRegions.then( - (regions) => regions.failedOnly - .where((region) => region.storeName == widget.storeName) - .map((region) => region.id), - ); - late final _tilesCount = FMTCStore(widget.storeName).stats.length; - late final _combinedFutures = (_recoveryRegions, _tilesCount).wait; - - late final _isDownloading = - context.read().storeName == widget.storeName; - - @override - Widget build(BuildContext context) => AlertDialog.adaptive( - icon: const Icon(Icons.delete_forever), - title: Text( - 'Empty/delete ${widget.storeName}?', - textAlign: TextAlign.center, - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - FutureBuilder( - future: _combinedFutures, - builder: (context, snapshot) { - if ((snapshot.data?.$1.length ?? 0) == 0) { - return const SizedBox.shrink(); - } - - return Text( - 'Deleting this store will also delete ' - '${snapshot.requireData.$1.length} associated recoverable ' - 'region(s).', - textAlign: TextAlign.center, - ); - }, - ), - const Text( - 'Emptying or deleting a store cannot be undone.', - textAlign: TextAlign.center, - ), - ], - ), - actions: [ - SizedBox( - height: 40, - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ), - SizedBox( - height: 40, - child: FutureBuilder( - future: _combinedFutures, - builder: (context, snapshot) { - if (snapshot.data == null) return const SizedBox.shrink(); - if (snapshot.requireData.$2 == 0) { - return const FilledButton.tonal( - onPressed: null, - child: Text('Empty'), - ); - } - return FilledButton.tonal( - onPressed: () => Navigator.of(context).pop( - ( - isDeleting: false, - future: FMTCStore(widget.storeName).manage.reset(), - ), - ), - child: Text('Empty ${snapshot.requireData.$2} tiles'), - ); - }, - ), - ), - SizedBox( - height: 40, - child: FutureBuilder( - future: _combinedFutures, - builder: (context, snapshot) { - if (snapshot.data == null) { - return const UnconstrainedBox( - child: SizedBox.square( - dimension: 30, - child: CircularProgressIndicator.adaptive(), - ), - ); - } - - final button = FilledButton( - onPressed: _isDownloading - ? null - : () => Navigator.of(context).pop( - ( - isDeleting: true, - future: Future.wait( - [ - FMTCStore(widget.storeName).manage.delete(), - ...snapshot.requireData.$1.map( - (id) => FMTCRoot.recovery.cancel(id), - ), - ], - ) - ), - ), - child: const Text('Delete'), - ); - - if (!_isDownloading) return button; - - return Tooltip( - message: - 'Cannot delete store whilst a\ndownload is in progress', - textAlign: TextAlign.center, - child: button, - ); - }, - ), - ), - ], - ); -} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart index 5268c730..4bc24018 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart @@ -4,18 +4,12 @@ class _Trailing extends StatelessWidget { const _Trailing({ required this.storeName, required this.matchesUrl, - required this.isToolsVisible, - required this.isDeleting, required this.useCompactLayout, - required this.toolsChildren, }); final String storeName; final bool matchesUrl; - final bool isToolsVisible; - final bool isDeleting; final bool useCompactLayout; - final List toolsChildren; @override Widget build(BuildContext context) { @@ -53,85 +47,6 @@ class _Trailing extends StatelessWidget { ), ); - final tools = AnimatedOpacity( - opacity: isToolsVisible ? 1 : 0, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: !isToolsVisible, - child: SizedBox.expand( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceDim, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - ), - child: isDeleting - ? const Center( - child: SizedBox.square( - dimension: 25, - child: Center( - child: CircularProgressIndicator.adaptive(), - ), - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: toolsChildren, - ), - ), - ), - ), - ), - ); - - final exportCheckbox = Selector>( - selector: (context, provider) => provider.selectedStores, - builder: (context, selectedStores, _) => AnimatedOpacity( - opacity: selectedStores.isNotEmpty ? 1 : 0, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: selectedStores.isEmpty, - child: SizedBox.expand( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceDim, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.note_add), - const SizedBox(width: 12), - Checkbox.adaptive( - value: selectedStores.contains(storeName), - onChanged: (v) { - if (v!) { - context - .read() - .addSelectedStore(storeName); - } else if (!v) { - context - .read() - .removeSelectedStore(storeName); - } - }, - ), - ], - ), - ), - ), - ), - ), - ), - ); - return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, @@ -152,8 +67,6 @@ class _Trailing extends StatelessWidget { ), ), urlMismatch, - tools, - exportCheckbox, ], ), ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart index 247d2222..2c5dfdcc 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/store_tile.dart @@ -1,15 +1,18 @@ import 'dart:async'; +import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:provider/provider.dart'; import '../../../../../../../../../../shared/misc/exts/size_formatter.dart'; import '../../../../../../../../../../shared/misc/store_metadata_keys.dart'; +import '../../../../../../../../../../shared/state/download_provider.dart'; import '../../../../../../../../../../shared/state/general_provider.dart'; import '../../../../../../../../../store_editor/store_editor.dart'; -import '../../../state/export_selection_provider.dart'; import 'components/browse_store_strategy_selector/browse_store_strategy_selector.dart'; -import 'components/store_empty_deletion_dialog.dart'; +import 'components/custom_single_slidable_action.dart'; part 'components/trailing.dart'; @@ -21,6 +24,7 @@ class StoreTile extends StatefulWidget { required this.metadata, required this.tileImage, required this.useCompactLayout, + this.isFirstStore = false, }); final String storeName; @@ -28,177 +32,268 @@ class StoreTile extends StatefulWidget { final Future> metadata; final Future tileImage; final bool useCompactLayout; + final bool isFirstStore; @override State createState() => _StoreTileState(); } -class _StoreTileState extends State { - bool _isToolsVisible = false; +class _StoreTileState extends State + with SingleTickerProviderStateMixin { + static const _dismissThreshold = 0.25; + + late final _slidableController = SlidableController(this); + bool _isEmptying = false; - bool _isDeleting = false; - Timer? _toolsAutoHiderTimer; + + @override + void initState() { + super.initState(); + + if (widget.isFirstStore) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => Future.delayed(const Duration(seconds: 1), _hintTools), + ); + } + } @override void dispose() { - _toolsAutoHiderTimer?.cancel(); + _slidableController.dispose(); super.dispose(); } @override Widget build(BuildContext context) => RepaintBoundary( - child: Material( - color: Colors.transparent, - child: FutureBuilder( - future: widget.metadata, - builder: (context, metadataSnapshot) { - final matchesUrl = metadataSnapshot.data != null && - context.select( - (provider) => provider.urlTemplate, - ) == - metadataSnapshot.data![StoreMetadataKeys.urlTemplate.key]; - - final toolsChildren = [ - const SizedBox(width: 4), - IconButton( - onPressed: _exportStore, - icon: const Icon(Icons.send_and_archive), - visualDensity: - widget.useCompactLayout ? VisualDensity.compact : null, - ), - const SizedBox(width: 4), - IconButton( - onPressed: _editStore, - icon: const Icon(Icons.edit), - visualDensity: - widget.useCompactLayout ? VisualDensity.compact : null, - ), - const SizedBox(width: 4), - if (_isEmptying) - const IconButton( - onPressed: null, - icon: SizedBox.square( - dimension: 18, - child: Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 3, - ), + child: ClipRect( + child: LayoutBuilder( + builder: (context, outerConstraints) => Slidable( + key: ValueKey(widget.storeName), + controller: _slidableController, + closeOnScroll: false, + enabled: !_isEmptying, + startActionPane: ActionPane( + motion: const BehindMotion(), + extentRatio: double.minPositive, + dismissible: DismissiblePane( + dismissThreshold: _dismissThreshold, + onDismissed: () {}, + confirmDismiss: () async { + unawaited( + Navigator.of(context).pushNamed( + StoreEditorPopup.route, + arguments: widget.storeName, ), - ), - ) - else - IconButton( - onPressed: _emptyDeleteStore, - icon: const Icon(Icons.delete), - visualDensity: - widget.useCompactLayout ? VisualDensity.compact : null, - ), - const SizedBox(width: 4), - ]; - - return InkWell( - onSecondaryTap: _showTools, - child: ListTile( - title: Text( - widget.storeName, - maxLines: 1, - overflow: TextOverflow.fade, - softWrap: false, + ); + return false; + }, + closeOnCancel: true, + ), + children: [ + CustomSingleSlidableAction( + key: ValueKey('${widget.storeName} edit'), + unconfirmedIcon: Icons.edit_outlined, + confirmedIcon: Icons.edit, + color: Colors.blue, + alignment: Alignment.centerLeft, + dismissThreshold: + outerConstraints.maxWidth * _dismissThreshold, ), - subtitle: FutureBuilder( + ], + ), + endActionPane: ActionPane( + motion: const BehindMotion(), + extentRatio: double.minPositive, + dismissible: DismissiblePane( + dismissThreshold: _dismissThreshold, + onDismissed: () {}, + confirmDismiss: () => + _emptyOrDelete(outerConstraints: outerConstraints), + closeOnCancel: true, + ), + children: [ + FutureBuilder( future: widget.stats, - builder: (context, statsSnapshot) { - if (statsSnapshot.data case final stats?) { - return Text( - '${(stats.size * 1024).asReadableSize} | ' - '${stats.length} tiles', + builder: (context, snapshot) { + final length = snapshot.data?.length; + if (length == null || length > 0) { + return CustomSingleSlidableAction( + key: ValueKey('${widget.storeName} empty'), + unconfirmedIcon: Icons.layers_clear_outlined, + confirmedIcon: Icons.layers_clear, + color: Colors.deepOrange, + alignment: Alignment.centerRight, + dismissThreshold: + outerConstraints.maxWidth * _dismissThreshold, + showLoader: _isEmptying, ); } - return const Text('Loading stats...'); + + return CustomSingleSlidableAction( + key: ValueKey('${widget.storeName} delete'), + unconfirmedIcon: Icons.delete_forever_outlined, + confirmedIcon: Icons.delete_forever, + color: Colors.red, + alignment: Alignment.centerRight, + dismissThreshold: + outerConstraints.maxWidth * _dismissThreshold, + ); }, ), - leading: AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: RepaintBoundary( - child: FutureBuilder( - future: widget.tileImage, - builder: (context, snapshot) { - if (snapshot.data case final data?) return data; - return const Icon(Icons.filter_none); - }, + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onSecondaryTap: _hintTools, + onLongPress: _hintTools, + mouseCursor: SystemMouseCursors.basic, + child: ListTile( + title: Text( + widget.storeName, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + ), + subtitle: FutureBuilder( + future: widget.stats, + builder: (context, statsSnapshot) { + if (statsSnapshot.data case final stats?) { + return Text( + '${(stats.size * 1024).asReadableSize} | ' + '${stats.length} tiles', + ); + } + return const Text('Loading stats...'); + }, + ), + leading: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: RepaintBoundary( + child: FutureBuilder( + future: widget.tileImage, + builder: (context, snapshot) { + if (snapshot.data case final data?) return data; + return const Icon(Icons.filter_none); + }, + ), ), ), ), + trailing: FutureBuilder( + future: widget.metadata, + builder: (context, snapshot) => _Trailing( + storeName: widget.storeName, + matchesUrl: snapshot.data != null && + context.select( + (provider) => provider.urlTemplate, + ) == + snapshot + .data![StoreMetadataKeys.urlTemplate.key], + useCompactLayout: widget.useCompactLayout, + ), + ), ), - trailing: _Trailing( - storeName: widget.storeName, - matchesUrl: matchesUrl, - isToolsVisible: _isToolsVisible, - isDeleting: _isDeleting, - useCompactLayout: widget.useCompactLayout, - toolsChildren: toolsChildren, - ), - onLongPress: _showTools, - onTap: _hideTools, ), - ); - }, + ), + ), ), ), ); - Future _exportStore() async { - context.read().addSelectedStore(widget.storeName); - await _hideTools(); - } - - Future _editStore() async { - await Navigator.of(context).pushNamed( - StoreEditorPopup.route, - arguments: widget.storeName, + Future _hintTools() async { + await _slidableController.openTo( + 0, + curve: Curves.easeOut, + ); + await _slidableController.openTo( + -(_dismissThreshold - 0.01), + curve: Curves.easeOut, + ); + await Future.delayed(const Duration(milliseconds: 400)); + await _slidableController.openTo( + 0, + curve: Curves.easeIn, + ); + await _slidableController.openTo( + _dismissThreshold - 0.01, + curve: Curves.easeOut, + ); + await Future.delayed(const Duration(milliseconds: 400)); + await _slidableController.openTo( + 0, + curve: Curves.easeIn, ); - await _hideTools(); } - Future _emptyDeleteStore() async { - _toolsAutoHiderTimer?.cancel(); + Future _emptyOrDelete({ + required BoxConstraints outerConstraints, + }) async { + if ((await widget.stats).length == 0) { + if (!mounted) return false; - final result = await showDialog<({Future future, bool isDeleting})>( - context: context, - builder: (context) => - StoreEmptyDeletionDialog(storeName: widget.storeName), - ); + if (context.read().storeName == widget.storeName) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cannot delete store whilst download is in progress'), + ), + ); + return false; + } - if (result == null) { - setState(() => _isToolsVisible = false); - return; - } + unawaited( + () async { + await Future.delayed(const Duration(milliseconds: 500)); + + final deletedRecoveryRegions = await FMTCRoot + .recovery.recoverableRegions + .then( + (regions) => regions.failedOnly + .where((region) => region.storeName == widget.storeName) + .map((region) => region.id), + ) + .then((ids) => Future.wait(ids.map(FMTCRoot.recovery.cancel))); - if (result.isDeleting) { - setState(() => _isDeleting = true); - await result.future; - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Deleted ${widget.storeName}')), + await FMTCStore(widget.storeName).manage.delete(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Deleted ${widget.storeName}' + '${deletedRecoveryRegions.isEmpty ? '' : ' and associated ' + 'recovery regions'}', + ), + ), + ); + }(), ); - return; + + return true; } + unawaited( + _slidableController.openTo( + -max( + _dismissThreshold, + 104 / outerConstraints.maxWidth, + ), + curve: Curves.easeOut, + ), + ); + setState(() => _isEmptying = true); - await result.future; - setState(() => _isEmptying = false); - } - Future _hideTools() async { - setState(() => _isToolsVisible = false); - _toolsAutoHiderTimer?.cancel(); - return Future.delayed(const Duration(milliseconds: 150)); - } + await FMTCStore(widget.storeName).manage.reset(); + + Future.delayed( + const Duration(milliseconds: 200), + () => setState(() => _isEmptying = false), + ); - void _showTools() { - setState(() => _isToolsVisible = true); - _toolsAutoHiderTimer = Timer(const Duration(seconds: 5), _hideTools); + return false; } } diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart index b9064de3..c080ab28 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart @@ -1,16 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; import '../../../../../../../shared/misc/exts/size_formatter.dart'; import 'components/column_headers_and_inheritable_settings.dart'; -import 'components/export_stores/button.dart'; import 'components/new_store_button.dart'; import 'components/no_stores.dart'; import 'components/tiles/root_tile.dart'; import 'components/tiles/store_tile/store_tile.dart'; import 'components/tiles/unspecified_tile.dart'; -import 'state/export_selection_provider.dart'; class StoresList extends StatefulWidget { const StoresList({ @@ -25,6 +22,8 @@ class StoresList extends StatefulWidget { } class _StoresListState extends State { + String? _firstStoreName; + late Future _rootLength; late Future _rootSize; late Future _rootRealSizeAdditional; @@ -70,7 +69,9 @@ class _StoresListState extends State { final stores = snapshot.data!; - if (stores.isEmpty) return const NoStores(); + if (stores.isEmpty) { + return NoStores(newStoreName: (store) => _firstStoreName = store); + } return SliverList.separated( itemCount: stores.length + 4, @@ -93,14 +94,7 @@ class _StoresListState extends State { ); } if (index - 3 == stores.length) { - return Builder( - builder: (context) => - context.select( - (p) => p.selectedStores.isEmpty, - ) - ? const NewStoreButton() - : const ExportStoresButton(), - ); + return const NewStoreButton(); } final store = stores.keys.elementAt(index - 1); @@ -109,12 +103,13 @@ class _StoresListState extends State { final tileImage = stores.values.elementAt(index - 1).tileImage; return StoreTile( - key: ValueKey(store), + key: ValueKey(store.storeName), storeName: store.storeName, stats: stats, metadata: metadata, tileImage: tileImage, useCompactLayout: widget.useCompactLayout, + isFirstStore: _firstStoreName == store.storeName, ); }, separatorBuilder: (context, index) => index - 3 == stores.length - 1 diff --git a/example/lib/src/screens/store_editor/store_editor.dart b/example/lib/src/screens/store_editor/store_editor.dart index 9118d138..5c75b519 100644 --- a/example/lib/src/screens/store_editor/store_editor.dart +++ b/example/lib/src/screens/store_editor/store_editor.dart @@ -88,6 +88,7 @@ class _StoreEditorPopupState extends State { initialValue: existingStoreName, textCapitalization: TextCapitalization.words, textInputAction: TextInputAction.next, + autofocus: true, ), ), const SizedBox(height: 6), @@ -190,7 +191,8 @@ class _StoreEditorPopupState extends State { } if (!context.mounted) return; - Navigator.of(context).pop(); + Navigator.of(context) + .pop(existingStoreName == null ? newName : null); }, child: existingStoreName == null ? const Icon(Icons.save) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 8042be3a..4c6647ab 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: flutter_map: flutter_map_animations: ^0.8.0 flutter_map_tile_caching: + flutter_slidable: ^3.1.2 google_fonts: ^6.2.1 gpx: ^2.3.0 http: ^1.2.2 From 7326b1cbbe20f7f54d69f92d4cd87d01068469c1 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 6 Jan 2025 16:10:12 +0000 Subject: [PATCH 89/97] Update example app config files for Android & Windows --- example/.metadata | 10 ++++---- .../app/{build.gradle => build.gradle.kts} | 12 +++++---- .../{example => fmtc_demo}/MainActivity.kt | 0 example/android/build.gradle | 18 ------------- example/android/build.gradle.kts | 21 ++++++++++++++++ example/android/gradle.properties | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle | 25 ------------------- example/android/settings.gradle.kts | 25 +++++++++++++++++++ example/pubspec.yaml | 5 ++-- example/windows/CMakeLists.txt | 10 ++++++-- example/windows/runner/Runner.rc | 4 +-- example/windows/runner/runner.exe.manifest | 6 ----- example/windows/runner/utils.cpp | 4 +-- windowsApplicationInstallerSetup.iss | 7 +++--- 15 files changed, 78 insertions(+), 73 deletions(-) rename example/android/app/{build.gradle => build.gradle.kts} (67%) rename example/android/app/src/main/kotlin/com/example/{example => fmtc_demo}/MainActivity.kt (100%) delete mode 100644 example/android/build.gradle create mode 100644 example/android/build.gradle.kts delete mode 100644 example/android/settings.gradle create mode 100644 example/android/settings.gradle.kts diff --git a/example/.metadata b/example/.metadata index f9c620ae..dd1f2f95 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30" + revision: "3e493a3e4d0a5c99fa7da51faae354e95a9a1abe" channel: "beta" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 - base_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 + create_revision: 3e493a3e4d0a5c99fa7da51faae354e95a9a1abe + base_revision: 3e493a3e4d0a5c99fa7da51faae354e95a9a1abe - platform: android - create_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 - base_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 + create_revision: 3e493a3e4d0a5c99fa7da51faae354e95a9a1abe + base_revision: 3e493a3e4d0a5c99fa7da51faae354e95a9a1abe # User provided section diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle.kts similarity index 67% rename from example/android/app/build.gradle rename to example/android/app/build.gradle.kts index f7cc2822..8e7323ff 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle.kts @@ -1,8 +1,8 @@ plugins { - id "com.android.application" - id "kotlin-android" + id("com.android.application") + id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id "dev.flutter.flutter-gradle-plugin" + id("dev.flutter.flutter-gradle-plugin") } android { @@ -17,7 +17,7 @@ android { } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 + jvmTarget = JavaVersion.VERSION_1_8.toString() } defaultConfig { @@ -30,7 +30,9 @@ android { buildTypes { release { - signingConfig = signingConfigs.debug + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") } } } diff --git a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/fmtc_demo/MainActivity.kt similarity index 100% rename from example/android/app/src/main/kotlin/com/example/example/MainActivity.kt rename to example/android/app/src/main/kotlin/com/example/fmtc_demo/MainActivity.kt diff --git a/example/android/build.gradle b/example/android/build.gradle deleted file mode 100644 index dd592163..00000000 --- a/example/android/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = "../build" - -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" - project.evaluationDependsOn(":app") -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} - diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 00000000..89176ef4 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 25971708..f018a618 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 348c409e..afa1e8eb 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle deleted file mode 100644 index ebe6f9c1..00000000 --- a/example/android/settings.gradle +++ /dev/null @@ -1,25 +0,0 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.7.3' apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false -} - -include ":app" diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 00000000..a439442c --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 4c6647ab..b48b6e7c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,5 @@ -name: fmtc_example -description: The example application for 'flutter_map_tile_caching', showcasing - it's functionality and use-cases. +name: fmtc_demo +description: The demo app for 'flutter_map_tile_caching', showcasing its functionality and use-cases. publish_to: "none" version: 10.0.0 diff --git a/example/windows/CMakeLists.txt b/example/windows/CMakeLists.txt index c09389c5..bdd33e6d 100644 --- a/example/windows/CMakeLists.txt +++ b/example/windows/CMakeLists.txt @@ -1,10 +1,10 @@ # Project-level configuration. cmake_minimum_required(VERSION 3.14) -project(example LANGUAGES CXX) +project(fmtc_demo LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "example") +set(BINARY_NAME "fmtc_demo") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. @@ -87,6 +87,12 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc index 13007a4b..80181afa 100644 --- a/example/windows/runner/Runner.rc +++ b/example/windows/runner/Runner.rc @@ -93,8 +93,8 @@ BEGIN VALUE "FileDescription", "FMTC Demo" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "FMTC Demo" "\0" - VALUE "LegalCopyright", "Copyright (C) 2023 JaffaKetchup. All rights reserved." "\0" - VALUE "OriginalFilename", "example.exe" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 JaffaKetchup. All rights reserved." "\0" + VALUE "OriginalFilename", "fmtc_demo.exe" "\0" VALUE "ProductName", "FMTC Demo" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END diff --git a/example/windows/runner/runner.exe.manifest b/example/windows/runner/runner.exe.manifest index a42ea768..153653e8 100644 --- a/example/windows/runner/runner.exe.manifest +++ b/example/windows/runner/runner.exe.manifest @@ -9,12 +9,6 @@ - - - - - - diff --git a/example/windows/runner/utils.cpp b/example/windows/runner/utils.cpp index b2b08734..3a0b4651 100644 --- a/example/windows/runner/utils.cpp +++ b/example/windows/runner/utils.cpp @@ -45,13 +45,13 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string) { if (utf16_string == nullptr) { return std::string(); } - int target_length = ::WideCharToMultiByte( + unsigned int target_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, nullptr, 0, nullptr, nullptr) -1; // remove the trailing null character int input_length = (int)wcslen(utf16_string); std::string utf8_string; - if (target_length <= 0 || target_length > utf8_string.max_size()) { + if (target_length == 0 || target_length > utf8_string.max_size()) { return utf8_string; } utf8_string.resize(target_length); diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index 36eda351..98e3d217 100644 --- a/windowsApplicationInstallerSetup.iss +++ b/windowsApplicationInstallerSetup.iss @@ -5,8 +5,8 @@ #define MyAppPublisher "JaffaKetchup Development" #define MyAppURL "https://github.com/JaffaKetchup/flutter_map_tile_caching" #define MyAppSupportURL "https://github.com/JaffaKetchup/flutter_map_tile_caching/issues" -#define MyAppExeName "example.exe" -#define MyAppAssocName "Map Cache Store" +#define MyAppExeName "fmtc_demo.exe" +#define MyAppAssocName "FMTC Archive" #define MyAppAssocExt ".fmtc" #define MyAppAssocKey StringChange(MyAppAssocName, " ", "") + MyAppAssocExt @@ -63,12 +63,13 @@ Name: "ukrainian"; MessagesFile: "compiler:Languages\Ukrainian.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked -; Specify all files within 'build/windows/runner/Release' except 'example.exe' +; Specify all files within 'build/windows/runner/Release' except 'fmtc_demo.exe' [Files] Source: "example\build\windows\x64\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion Source: "example\build\windows\x64\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "example\build\windows\x64\runner\Release\objectbox_flutter_libs_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "example\build\windows\x64\runner\Release\objectbox.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "example\build\windows\x64\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "example\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs [Registry] From d9bd0458502535ea0596c3ab6bd2258fa5bf6f7d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 6 Jan 2025 16:21:53 +0000 Subject: [PATCH 90/97] Fixed Windows installer generation --- windowsApplicationInstallerSetup.iss | 1 + 1 file changed, 1 insertion(+) diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index 98e3d217..65baa309 100644 --- a/windowsApplicationInstallerSetup.iss +++ b/windowsApplicationInstallerSetup.iss @@ -70,6 +70,7 @@ Source: "example\build\windows\x64\runner\Release\flutter_windows.dll"; DestDir: Source: "example\build\windows\x64\runner\Release\objectbox_flutter_libs_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "example\build\windows\x64\runner\Release\objectbox.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "example\build\windows\x64\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "example\build\windows\x64\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "example\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs [Registry] From 190b90b0bb5966fc567d3ccf5cd263cc14ede481 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 6 Jan 2025 23:20:12 +0000 Subject: [PATCH 91/97] Re-added export functionality to example app Minor example app improvements --- example/lib/main.dart | 14 +- example/lib/src/screens/export/export.dart | 329 ++++++++++++++++++ example/lib/src/screens/import/import.dart | 1 - .../src/screens/import/stages/selection.dart | 2 +- .../confirmation_panel.dart | 8 +- ...lumn_headers_and_inheritable_settings.dart | 4 +- .../example_app_limitations_text.dart | 0 .../components/export_stores/button.dart | 184 ---------- .../export_stores/name_input_dialog.dart | 79 ----- .../export_stores/progress_dialog.dart | 24 -- .../components/new_store_button.dart | 6 +- .../stores_list/components/no_stores.dart | 2 +- .../custom_single_slidable_action.dart | 82 +++-- .../tiles/store_tile/components/trailing.dart | 29 +- .../state/export_selection_provider.dart | 20 -- .../tile_provider/tile_provider.dart | 2 +- lib/src/store/download.dart | 2 +- 17 files changed, 406 insertions(+), 382 deletions(-) create mode 100644 example/lib/src/screens/export/export.dart rename example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/{export_stores => }/example_app_limitations_text.dart (100%) delete mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart delete mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/name_input_dialog.dart delete mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/progress_dialog.dart delete mode 100644 example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index ca79325b..7ea60f76 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -4,10 +4,10 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'src/screens/export/export.dart'; import 'src/screens/import/import.dart'; import 'src/screens/initialisation_error/initialisation_error.dart'; import 'src/screens/main/main.dart'; -import 'src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart'; import 'src/screens/store_editor/store_editor.dart'; import 'src/shared/misc/shared_preferences.dart'; import 'src/shared/state/download_configuration_provider.dart'; @@ -66,6 +66,14 @@ class _AppContainer extends StatelessWidget { fullscreenDialog: true, ), ), + ExportPopup.route: ( + std: null, + custom: (context, settings) => MaterialPageRoute( + builder: (context) => const ExportPopup(), + settings: settings, + fullscreenDialog: true, + ), + ), }; @override @@ -97,10 +105,6 @@ class _AppContainer extends StatelessWidget { ChangeNotifierProvider( create: (_) => GeneralProvider(), ), - ChangeNotifierProvider( - create: (_) => ExportSelectionProvider(), - lazy: true, - ), ChangeNotifierProvider( create: (_) => RegionSelectionProvider(), lazy: true, diff --git a/example/lib/src/screens/export/export.dart b/example/lib/src/screens/export/export.dart new file mode 100644 index 00000000..02dbaffb --- /dev/null +++ b/example/lib/src/screens/export/export.dart @@ -0,0 +1,329 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +class ExportPopup extends StatefulWidget { + const ExportPopup({super.key}); + + static const String route = '/export'; + + @override + State createState() => _ExportPopupState(); +} + +class _ExportPopupState extends State { + late final _inputController = TextEditingController(); + + final _availableStores = FMTCRoot.stats.storesAvailable; + + final _selectedStores = {}; + + bool _isExporting = false; + bool _isVerifying = false; + bool _isInvalid = false; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Export Stores'), + ), + body: FutureBuilder( + future: _availableStores, + builder: (context, snapshot) { + if (snapshot.data == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 12), + Text( + 'Loading stores', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ); + } + + final stores = snapshot.requireData; + + assert( + stores.isNotEmpty, + 'This route should not be navigable if there are no stores', + ); + + final isMobilePlatform = Platform.isAndroid || Platform.isIOS; + + final exportLoader = Padding( + key: const ValueKey('exportLoader'), + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Stack( + alignment: Alignment.center, + children: [ + SizedBox.square( + dimension: 64, + child: CircularProgressIndicator.adaptive(), + ), + Icon(Icons.send_and_archive, size: 32), + ], + ), + const SizedBox(width: 32), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Exporting selected stores...', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.bold), + ), + const Text( + "Please don't close this dialog or leave the app.\n" + 'The operation will continue if the dialog is ' + "closed.\nWe'll let you know once we're done.", + ), + ], + ), + ), + ], + ), + ); + + final pathInput = Padding( + key: const ValueKey('pathInput'), + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + spacing: 8, + children: [ + Expanded( + child: TextFormField( + controller: _inputController, + enabled: !_isVerifying, + decoration: InputDecoration( + suffixText: isMobilePlatform ? '.fmtc' : null, + filled: true, + label: isMobilePlatform + ? const Text('Archive name') + : const Text('Archive path'), + errorText: _isInvalid ? 'Invalid name' : null, + ), + onChanged: (_) => setState(() => _isInvalid = false), + ), + ), + if (!isMobilePlatform) + SizedBox( + height: 38, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.centerRight, + child: ValueListenableBuilder( + valueListenable: _inputController, + builder: (context, controller, _) { + if (controller.text.isEmpty) { + return FilledButton.icon( + onPressed: _launchPlatformPicker, + icon: const Icon(Icons.note_add), + label: const Text('Select file'), + ); + } else { + return IconButton.filledTonal( + onPressed: _launchPlatformPicker, + icon: const Icon(Icons.note_add), + ); + } + }, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) => Align( + alignment: Alignment.centerRight, + child: SizedBox( + height: 38, + width: + constraints.maxWidth > 500 ? 250 : double.infinity, + child: ValueListenableBuilder( + valueListenable: _inputController, + builder: (context, controller, _) { + final enabled = _selectedStores.isEmpty || + _isVerifying || + controller.text.isEmpty; + + return FilledButton.icon( + onPressed: enabled ? null : _verifyAndExport, + icon: _isVerifying + ? null + : const Icon(Icons.send_and_archive), + label: _isVerifying + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator.adaptive( + strokeWidth: 3, + ), + ) + : Text( + 'Create archive & ' + '${isMobilePlatform ? 'share' : 'save'}', + ), + ); + }, + ), + ), + ), + ), + ], + ), + ); + + return Column( + children: [ + Expanded( + child: Scrollbar( + child: ListView.builder( + itemCount: stores.length, + itemBuilder: (context, index) { + final store = stores[index]; + + return CheckboxListTile.adaptive( + title: Text(store.storeName), + value: _selectedStores.contains(store), + onChanged: _isVerifying + ? null + : (value) { + if (value!) { + _selectedStores.add(store); + } else { + _selectedStores.remove(store); + } + setState(() {}); + }, + ); + }, + ), + ), + ), + ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainer, + child: SizedBox( + width: double.infinity, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) => SlideTransition( + position: (animation.value == 1 + ? Tween( + begin: const Offset(-1, 0), + end: Offset.zero, + ) + : Tween( + begin: const Offset(1, 0), + end: Offset.zero, + )) + .animate(animation), + child: child, + ), + child: _isExporting ? exportLoader : pathInput, + ), + ), + ), + ], + ); + }, + ), + ); + + Future _launchPlatformPicker() async { + final filePath = await FilePicker.platform.saveFile( + dialogTitle: 'Export Stores', + fileName: 'export.fmtc', + type: FileType.custom, + allowedExtensions: ['fmtc'], + ); + if (filePath == null) return; + _inputController.text = filePath; + setState(() => _isInvalid = false); + } + + Future _verifyAndExport() async { + void errorOut() { + if (!mounted) return; + setState(() { + _isVerifying = false; + _isInvalid = true; + }); + } + + setState(() => _isVerifying = true); + + late final String path; + + if (Platform.isAndroid || Platform.isIOS) { + final tempDir = + p.join((await getTemporaryDirectory()).absolute.path, 'fmtc_export'); + path = p.join(tempDir, '${_inputController.text}.fmtc.tmp'); + } else { + path = _inputController.text; + + late final FileSystemEntityType selectedType; + try { + selectedType = await FileSystemEntity.type(path); + } on FileSystemException { + return errorOut(); + } + if (selectedType != FileSystemEntityType.notFound && + selectedType != FileSystemEntityType.file) { + return errorOut(); + } + } + + final file = File(path); + try { + await file.create(); + await file.delete(); + } on FileSystemException { + return errorOut(); + } + + if (!mounted) return; + setState(() => _isExporting = true); + + final stopwatch = Stopwatch()..start(); + + final tilesCount = await FMTCRoot.external(pathToArchive: path).export( + storeNames: + _selectedStores.map((s) => s.storeName).toList(growable: false), + ); + + stopwatch.stop(); + + if (Platform.isAndroid || Platform.isIOS) { + await Share.shareXFiles([XFile(path)]); + await File(path).delete(recursive: true); + } + + if (!mounted) return; + Navigator.of(context).pop(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar( + content: Text('Exported $tilesCount tiles in ${stopwatch.elapsed}'), + ), + ); + } +} diff --git a/example/lib/src/screens/import/import.dart b/example/lib/src/screens/import/import.dart index 6006c95c..48444cfe 100644 --- a/example/lib/src/screens/import/import.dart +++ b/example/lib/src/screens/import/import.dart @@ -81,7 +81,6 @@ class _ImportPopupState extends State { appBar: AppBar( title: const Text('Import Archive'), automaticallyImplyLeading: stage != 3, - elevation: 1, ), body: AnimatedSwitcher( duration: const Duration(milliseconds: 250), diff --git a/example/lib/src/screens/import/stages/selection.dart b/example/lib/src/screens/import/stages/selection.dart index ecb5cdc2..734efcfe 100644 --- a/example/lib/src/screens/import/stages/selection.dart +++ b/example/lib/src/screens/import/stages/selection.dart @@ -95,7 +95,7 @@ class _ImportSelectionStageState extends State { children: [ Icon(icon), const SizedBox(width: 12), - Text(text), + Expanded(child: Text(text)), ], ), ); 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 ef1eeb58..aecaeec4 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 @@ -160,13 +160,19 @@ class _ConfirmationPanelState extends State { Text( "You must abide by your tile server's Terms of " 'Service when bulk downloading.', - style: TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), 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.', + style: TextStyle( + color: Colors.black, + ), ), ], ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart index a84dcec0..03885b65 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart @@ -102,9 +102,7 @@ class ColumnHeadersAndInheritableSettings extends StatelessWidget { value: e, alignment: Alignment.center, child: switch (e) { - null => const Icon( - Icons.disabled_by_default_rounded, - ), + null => const Icon(Icons.close), BrowseStoreStrategy.read => const Icon(Icons.visibility), BrowseStoreStrategy.readUpdate => diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/example_app_limitations_text.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/example_app_limitations_text.dart similarity index 100% rename from example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/example_app_limitations_text.dart rename to example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/example_app_limitations_text.dart diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart deleted file mode 100644 index 533899d2..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/button.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:provider/provider.dart'; -import 'package:share_plus/share_plus.dart'; - -import '../../state/export_selection_provider.dart'; -import 'example_app_limitations_text.dart'; - -part 'name_input_dialog.dart'; -part 'progress_dialog.dart'; - -class ExportStoresButton extends StatelessWidget { - const ExportStoresButton({super.key}); - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Column( - children: [ - IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: SizedBox( - height: double.infinity, - child: FilledButton.tonalIcon( - label: const Text('Export selected stores'), - icon: const Icon(Icons.send_and_archive), - onPressed: () => _export(context), - ), - ), - ), - const SizedBox(width: 8), - IconButton.outlined( - icon: const Icon(Icons.cancel), - tooltip: 'Cancel export', - onPressed: () => context - .read() - .clearSelectedStores(), - ), - ], - ), - ), - const SizedBox(height: 24), - Text( - exampleAppLimitationsText, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ), - ); - - Future _export(BuildContext context) async { - Future showOverwriteConfirmationDialog(BuildContext context) => - showDialog( - context: context, - builder: (context) => AlertDialog.adaptive( - title: const Text( - 'Overwrite existing file?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Overwrite'), - ), - ], - ), - ); - - final provider = context.read(); - final fileNameTime = - DateTime.now().toString().split('.').first.replaceAll(':', '-'); - - final String filePath; - late final String tempDir; - if (Platform.isAndroid || Platform.isIOS) { - tempDir = p.join( - (await getTemporaryDirectory()).absolute.path, - 'fmtc_export', - ); - await Directory(tempDir).create(recursive: true); - - if (!context.mounted) { - provider.clearSelectedStores(); - return; - } - - final name = await showDialog( - context: context, - builder: (context) => _ExportingNameInputDialog( - defaultName: 'export ($fileNameTime)', - tempDir: tempDir, - ), - ); - if (name == null) return; - - filePath = p.join(tempDir, '$name.fmtc'); - } else { - final intermediateFilePath = await FilePicker.platform.saveFile( - dialogTitle: 'Export Stores', - fileName: 'export ($fileNameTime).fmtc', - type: FileType.custom, - allowedExtensions: ['fmtc'], - ); - - if (intermediateFilePath == null) return; - final selectedType = await FileSystemEntity.type(intermediateFilePath); - - if (!context.mounted) { - provider.clearSelectedStores(); - return; - } - - const invalidTypeSnackbar = SnackBar( - content: Text( - 'Cannot start export: must be a file or non-existent', - ), - ); - - switch (selectedType) { - case FileSystemEntityType.notFound: - break; - case FileSystemEntityType.file: - if ((Platform.isAndroid || Platform.isIOS) && - (await showOverwriteConfirmationDialog(context) ?? false)) { - return; - } - // We do indeed want a default here - // ignore: no_default_cases - default: - ScaffoldMessenger.maybeOf(context)?.showSnackBar(invalidTypeSnackbar); - return; - } - - filePath = intermediateFilePath; - } - - if (!context.mounted) { - provider.clearSelectedStores(); - return; - } - - unawaited( - showDialog( - context: context, - builder: (context) => const _ExportingProgressDialog(), - barrierDismissible: false, - ), - ); - - final startTime = DateTime.timestamp(); - final tiles = await FMTCRoot.external(pathToArchive: filePath) - .export(storeNames: provider.selectedStores); - - provider.clearSelectedStores(); - - if (!context.mounted) return; - Navigator.of(context).pop(); - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - SnackBar( - content: Text( - 'Exported $tiles tiles in ' - '${DateTime.timestamp().difference(startTime)}', - ), - ), - ); - - if (Platform.isAndroid || Platform.isIOS) { - await Share.shareXFiles([XFile(filePath)]); - await Directory(tempDir).delete(recursive: true); - } - } -} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/name_input_dialog.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/name_input_dialog.dart deleted file mode 100644 index 00338305..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/name_input_dialog.dart +++ /dev/null @@ -1,79 +0,0 @@ -part of 'button.dart'; - -class _ExportingNameInputDialog extends StatefulWidget { - const _ExportingNameInputDialog({ - required this.defaultName, - required this.tempDir, - }); - - final String defaultName; - final String tempDir; - - @override - State<_ExportingNameInputDialog> createState() => - _ExportingNameInputDialogState(); -} - -class _ExportingNameInputDialogState extends State<_ExportingNameInputDialog> { - final inputController = TextEditingController(); - - bool invalidFilename = false; - - @override - Widget build(BuildContext context) => AlertDialog.adaptive( - icon: const Icon(Icons.send_and_archive), - title: const Text('Choose archive name'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: inputController, - decoration: InputDecoration( - hintText: widget.defaultName, - suffixText: '.fmtc', - errorText: invalidFilename ? 'Invalid filename' : null, - ), - onChanged: (_) => setState(() => invalidFilename = false), - onFieldSubmitted: (_) => _validateAndFinish(), - autofocus: true, - ), - const SizedBox(height: 12), - const Text( - "Once we're done, we'll let you share the exported archive " - 'elsewhere.', - textAlign: TextAlign.center, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _validateAndFinish, - child: const Text('Export'), - ), - ], - ); - - Future _validateAndFinish() async { - if (inputController.text.isEmpty) { - Navigator.of(context).pop(widget.defaultName); - return; - } - - final file = File( - p.join(widget.tempDir, '${inputController.text}.fmtc.tmp'), - ); - try { - await file.create(); - await file.delete(); - } on FileSystemException { - setState(() => invalidFilename = true); - return; - } - - if (mounted) Navigator.of(context).pop(inputController.text); - } -} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/progress_dialog.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/progress_dialog.dart deleted file mode 100644 index 40af4f1b..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/export_stores/progress_dialog.dart +++ /dev/null @@ -1,24 +0,0 @@ -part of 'button.dart'; - -class _ExportingProgressDialog extends StatelessWidget { - const _ExportingProgressDialog(); - - @override - Widget build(BuildContext context) => const AlertDialog.adaptive( - icon: Icon(Icons.send_and_archive), - title: Text('Export in progress'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator.adaptive(), - SizedBox(height: 12), - Text( - "Please don't close this dialog or leave the app.\nThe operation " - "will continue if the dialog is closed.\nWe'll let you know once " - "we're done.", - textAlign: TextAlign.center, - ), - ], - ), - ); -} diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart index be87321a..7294ef4c 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import '../../../../../../../export/export.dart'; import '../../../../../../../import/import.dart'; import '../../../../../../../store_editor/store_editor.dart'; -import 'export_stores/example_app_limitations_text.dart'; +import 'example_app_limitations_text.dart'; class NewStoreButton extends StatefulWidget { const NewStoreButton({super.key}); @@ -57,7 +58,8 @@ class _NewStoreButtonState extends State { child: SizedBox( height: 38, child: OutlinedButton.icon( - onPressed: () {}, + onPressed: () => Navigator.of(context) + .pushNamed(ExportPopup.route), icon: const Icon(Icons.send_and_archive), label: const Text('Export'), ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart index 7b3fe750..41b9278a 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../../../../import/import.dart'; import '../../../../../../../store_editor/store_editor.dart'; -import 'export_stores/example_app_limitations_text.dart'; +import 'example_app_limitations_text.dart'; class NoStores extends StatelessWidget { const NoStores({ diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/custom_single_slidable_action.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/custom_single_slidable_action.dart index d3f5a094..3d63d230 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/custom_single_slidable_action.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/custom_single_slidable_action.dart @@ -1,4 +1,3 @@ -import 'dart:ui'; import 'package:flutter/material.dart'; class CustomSingleSlidableAction extends StatefulWidget { @@ -48,7 +47,7 @@ class _CustomSingleSlidableActionState extends State } final _scaleTween = Tween(begin: 1, end: 1.15); - final _rotationTween = Tween(begin: 0, end: 0.06); + final _rotationTween = Tween(begin: 0, end: 0.05); double _prevMaxWidth = 0; @@ -72,16 +71,8 @@ class _CustomSingleSlidableActionState extends State WidgetsBinding.instance.addPostFrameCallback((_) { final box = _inkWellKey.currentContext!.findRenderObject()! as RenderBox; - final position = box.localToGlobal( - Offset( - lerpDouble( - 0, - box.size.width, - (widget.alignment.x.clamp(-1, 1) + 1) / 2, - )!, - box.size.height / 2, - ), - ); + final localPosition = Offset(12 + 16, box.size.height / 2); + final globalPosition = box.localToGlobal(localPosition); _inkWellKey.currentContext!.visitChildElements((element) { assert( @@ -97,11 +88,8 @@ class _CustomSingleSlidableActionState extends State // Shenanigans // ignore: avoid_dynamic_calls inkResponseState.handleTapDown( - TapDownDetails(globalPosition: position), + TapDownDetails(globalPosition: globalPosition), ); - // Shenanigans - // ignore: avoid_dynamic_calls - inkResponseState.handleLongPress(); }); }); } @@ -187,33 +175,41 @@ class _CustomSingleSlidableActionState extends State return Material( color: Colors.transparent, - child: InkWell( - key: _inkWellKey, - radius: innerConstraints.maxWidth, - splashFactory: InkSparkle.splashFactory, - canRequestFocus: false, - child: TweenAnimationBuilder( - tween: ColorTween( - begin: widget.color.withAlpha(204), - end: willAct ? widget.color : widget.color.withAlpha(204), - ), - duration: const Duration(milliseconds: 120), - curve: Curves.easeIn, - builder: (context, color, child) => Ink( - color: color, - padding: const EdgeInsets.symmetric(horizontal: 16), - height: double.infinity, - child: child, - ), - child: Opacity( - opacity: innerConstraints.maxWidth.clamp(0, 56) / 56, - child: Row( - mainAxisAlignment: widget.alignment.x > 0 - ? MainAxisAlignment.end - : MainAxisAlignment.start, - children: widget.alignment.x > 0 - ? [icon, loader] - : [loader, icon], + child: Transform.flip( + flipX: widget.alignment.x > 0, + child: InkWell( + key: _inkWellKey, + radius: innerConstraints.maxWidth, + splashFactory: InkSparkle.splashFactory, + canRequestFocus: false, + child: Transform.flip( + flipX: widget.alignment.x > 0, + child: TweenAnimationBuilder( + tween: ColorTween( + begin: widget.color.withAlpha(204), + end: willAct + ? widget.color + : widget.color.withAlpha(204), + ), + duration: const Duration(milliseconds: 120), + curve: Curves.easeIn, + builder: (context, color, child) => Ink( + color: color, + padding: const EdgeInsets.symmetric(horizontal: 16), + height: double.infinity, + child: child, + ), + child: Opacity( + opacity: innerConstraints.maxWidth.clamp(0, 56) / 56, + child: Row( + mainAxisAlignment: widget.alignment.x > 0 + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: widget.alignment.x > 0 + ? [icon, loader] + : [loader, icon], + ), + ), ), ), ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart index 4bc24018..b6064cbe 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/trailing.dart @@ -22,25 +22,22 @@ class _Trailing extends StatelessWidget { child: SizedBox.expand( child: DecoratedBox( decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.error.withValues(alpha: 0.75), + color: Colors.red.withValues(alpha: 0.75), borderRadius: BorderRadius.circular(12), ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Icon( - Icons.link_off, - color: Colors.white, - ), - Text( - 'URL mismatch', - style: TextStyle( - color: Colors.white, - fontSize: 14, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + Icon(Icons.link_off, color: Colors.white), + Text( + 'URL mismatch', + style: TextStyle(color: Colors.white, fontSize: 14), ), - ), - ], + ], + ), ), ), ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart deleted file mode 100644 index 0098b15a..00000000 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/state/export_selection_provider.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/foundation.dart'; - -class ExportSelectionProvider extends ChangeNotifier { - final List _selectedStores = []; - List get selectedStores => _selectedStores; - void addSelectedStore(String storeName) { - _selectedStores.add(storeName); - notifyListeners(); - } - - void removeSelectedStore(String storeName) { - _selectedStores.remove(storeName); - notifyListeners(); - } - - void clearSelectedStores() { - _selectedStores.clear(); - notifyListeners(); - } -} diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index d737ce46..a183fffb 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -177,7 +177,7 @@ class FMTCTileProvider extends TileProvider { /// Method used to create a tile's storage-suitable UID from it's real URL /// /// For more information, check the - /// [online documentation](https://fmtc.jaffaketchup.dev/basic-usage/integrating-with-a-map#ensure-tiles-are-resilient-to-url-changes). + /// [online documentation](https://fmtc.jaffaketchup.dev/usage/integrating-with-a-map#ensure-tiles-are-resilient-to-url-changes). /// /// The input string is the tile's URL. The output string should be a unique /// string to that tile that will remain as stable as necessary if parts of diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 376f7e7c..80937529 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -142,7 +142,7 @@ class StoreDownload { /// /// For info about [urlTransformer], see [FMTCTileProvider.urlTransformer] and /// the - /// [online documentation](https://fmtc.jaffaketchup.dev/basic-usage/integrating-with-a-map#ensure-tiles-are-resilient-to-url-changes). + /// [online documentation](https://fmtc.jaffaketchup.dev/usage/integrating-with-a-map#ensure-tiles-are-resilient-to-url-changes). /// /// > [!WARNING] /// > From a07d31a0693e6d882d5a12bcde60459d12046502 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 11 Jan 2025 16:36:11 +0000 Subject: [PATCH 92/97] Finished example app contents --- .gitignore | 1 + .../src/screens/main/layouts/vertical.dart | 60 +++-- .../additional_overlay.dart | 12 +- .../config_options/config_options.dart | 223 +++++++++--------- ...nload_configuration_view_bottom_sheet.dart | 58 ++++- .../components/statistics/statistics.dart | 30 ++- .../downloading_view_bottom_sheet.dart | 137 +++++++++++ .../downloading/downloading_view_side.dart | 2 +- .../example_app_limitations_text.dart | 5 +- .../region_selection_view_bottom_sheet.dart | 48 +++- .../layouts/bottom_sheet/bottom_sheet.dart | 14 +- .../bottom_sheet/utils/tab_header.dart | 2 +- 12 files changed, 410 insertions(+), 182 deletions(-) diff --git a/.gitignore b/.gitignore index a4fb7a9d..e4612c80 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ pubspec.lock **/android/gradlew.bat **/android/local.properties **/android/**/GeneratedPluginRegistrant.java +**/android/app/.cxx # iOS/XCode related **/ios/**/*.mode1v3 diff --git a/example/lib/src/screens/main/layouts/vertical.dart b/example/lib/src/screens/main/layouts/vertical.dart index d933dcd3..517d64f8 100644 --- a/example/lib/src/screens/main/layouts/vertical.dart +++ b/example/lib/src/screens/main/layouts/vertical.dart @@ -25,39 +25,37 @@ class _VerticalLayout extends StatelessWidget { controller: _bottomSheetOuterController, ), floatingActionButton: selectedTab == 1 && - context - .watch() - .constructedRegions - .isNotEmpty + context.select( + (provider) => + provider.constructedRegions.isNotEmpty && + !provider.isDownloadSetupPanelVisible, + ) ? DelayedControllerAttachmentBuilder( listenable: _bottomSheetOuterController, - builder: (context, _) => AnimatedBuilder( - animation: _bottomSheetOuterController, - builder: (context, _) { - final pixels = _bottomSheetOuterController.isAttached - ? _bottomSheetOuterController.pixels - : 0; - return FloatingActionButton( - onPressed: () async { - await _bottomSheetOuterController.animateTo( - 2 / 3, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ); - if (!context.mounted) return; - prepareDownloadConfigView( - context, - shouldShowConfig: pixels > 33, - ); - }, - tooltip: - pixels <= 33 ? 'Show regions' : 'Configure download', - child: pixels <= 33 - ? const Icon(Icons.library_add_check) - : const Icon(Icons.tune), - ); - }, - ), + builder: (context, _) { + final pixels = _bottomSheetOuterController.isAttached + ? _bottomSheetOuterController.pixels + : 0; + return FloatingActionButton( + onPressed: () async { + await _bottomSheetOuterController.animateTo( + 2 / 3, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + if (!context.mounted) return; + prepareDownloadConfigView( + context, + shouldShowConfig: pixels > 33, + ); + }, + tooltip: + pixels <= 33 ? 'Show regions' : 'Configure download', + child: pixels <= 33 + ? const Icon(Icons.library_add_check) + : const Icon(Icons.tune), + ); + }, ) : null, bottomNavigationBar: NavigationBar( diff --git a/example/lib/src/screens/main/map_view/components/additional_overlay/additional_overlay.dart b/example/lib/src/screens/main/map_view/components/additional_overlay/additional_overlay.dart index 213fb39d..5def22c1 100644 --- a/example/lib/src/screens/main/map_view/components/additional_overlay/additional_overlay.dart +++ b/example/lib/src/screens/main/map_view/components/additional_overlay/additional_overlay.dart @@ -48,14 +48,10 @@ class AdditionalOverlay extends StatelessWidget { listenable: bottomSheetOuterController, builder: (context, child) { if (!bottomSheetOuterController.isAttached) return child!; - return AnimatedBuilder( - animation: bottomSheetOuterController, - builder: (context, child) => _HeightZero( - useChildHeight: showShapeSelector && - bottomSheetOuterController.pixels <= 33, - child: child!, - ), - child: child, + return _HeightZero( + useChildHeight: showShapeSelector && + bottomSheetOuterController.pixels <= 33, + child: child!, ); }, child: Container( 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 8c9ec803..93c07e36 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 @@ -43,122 +43,119 @@ class _ConfigOptionsState extends State { final fromRecovery = context .select((p) => p.fromRecovery); - return SingleChildScrollView( - child: Column( - children: [ - StoreSelector( - storeName: storeName, - onStoreNameSelected: (storeName) => context - .read() - .selectedStoreName = storeName, - enabled: fromRecovery == null, - ), - const Divider(height: 24), - Row( - children: [ - const Tooltip(message: 'Zoom Levels', child: Icon(Icons.search)), - const SizedBox(width: 8), - Expanded( - child: RangeSlider( - values: RangeValues(minZoom.toDouble(), maxZoom.toDouble()), - max: 20, - divisions: 20, - onChanged: fromRecovery != null - ? null - : (r) => context.read() - ..minZoom = r.start.toInt() - ..maxZoom = r.end.toInt(), - ), + return Column( + children: [ + StoreSelector( + storeName: storeName, + onStoreNameSelected: (storeName) => context + .read() + .selectedStoreName = storeName, + enabled: fromRecovery == null, + ), + const Divider(height: 24), + Row( + children: [ + const Tooltip(message: 'Zoom Levels', child: Icon(Icons.search)), + const SizedBox(width: 8), + Expanded( + child: RangeSlider( + values: RangeValues(minZoom.toDouble(), maxZoom.toDouble()), + max: 20, + divisions: 20, + onChanged: fromRecovery != null + ? null + : (r) => context.read() + ..minZoom = r.start.toInt() + ..maxZoom = r.end.toInt(), ), - Text( - '${minZoom.toString().padLeft(2, '0')} - ' - '${maxZoom.toString().padLeft(2, '0')}', + ), + Text( + '${minZoom.toString().padLeft(2, '0')} - ' + '${maxZoom.toString().padLeft(2, '0')}', + ), + ], + ), + const Divider(height: 24), + _SliderOption( + icon: const Icon(Icons.call_split), + tooltipMessage: 'Parallel Threads', + descriptor: 'threads', + value: parallelThreads, + min: 1, + max: 10, + onChanged: (v) => + context.read().parallelThreads = v, + ), + const SizedBox(height: 8), + _SliderOption( + icon: const Icon(Icons.speed), + tooltipMessage: 'Rate Limit', + descriptor: 'tps max', + value: rateLimit, + min: 1, + max: 200, + onChanged: (v) => + context.read().rateLimit = v, + ), + const SizedBox(height: 8), + Row( + children: [ + const Tooltip( + message: 'Max Buffer Length', + child: Icon(Icons.memory), + ), + const SizedBox(width: 6), + Expanded( + child: Slider( + value: maxBufferLength.toDouble(), + max: 1000, + divisions: 1000, + onChanged: (r) => context + .read() + .maxBufferLength = r.toInt(), ), - ], - ), - const Divider(height: 24), - _SliderOption( - icon: const Icon(Icons.call_split), - tooltipMessage: 'Parallel Threads', - descriptor: 'threads', - value: parallelThreads, - min: 1, - max: 10, - onChanged: (v) => context - .read() - .parallelThreads = v, - ), - const SizedBox(height: 8), - _SliderOption( - icon: const Icon(Icons.speed), - tooltipMessage: 'Rate Limit', - descriptor: 'tps max', - value: rateLimit, - min: 1, - max: 200, - onChanged: (v) => - context.read().rateLimit = v, - ), - const SizedBox(height: 8), - Row( - children: [ - const Tooltip( - message: 'Max Buffer Length', - child: Icon(Icons.memory), + ), + SizedBox( + width: 71, + child: Text( + maxBufferLength == 0 ? 'Disabled' : '$maxBufferLength tiles', + textAlign: TextAlign.end, ), - const SizedBox(width: 6), - Expanded( - child: Slider( - value: maxBufferLength.toDouble(), - max: 1000, - divisions: 1000, - onChanged: (r) => context - .read() - .maxBufferLength = r.toInt(), - ), - ), - SizedBox( - width: 71, - child: Text( - maxBufferLength == 0 ? 'Disabled' : '$maxBufferLength tiles', - textAlign: TextAlign.end, - ), - ), - ], - ), - const Divider(height: 24), - _ToggleOption( - icon: const Icon(Icons.file_copy), - title: 'Skip Existing Tiles', - description: "Don't attempt tiles that are already cached", - value: skipExistingTiles, - onChanged: (v) => context - .read() - .skipExistingTiles = v, - ), - const SizedBox(height: 8), - _ToggleOption( - icon: const Icon(Icons.waves), - title: 'Skip Sea Tiles', - description: - "Don't cache tiles with sea/ocean fill as the only visible " - 'element', - value: skipSeaTiles, - onChanged: (v) => - context.read().skipSeaTiles = v, - ), - const SizedBox(height: 8), - _ToggleOption( - icon: const Icon(Icons.plus_one), - title: 'Retry Failed Tiles', - description: 'Retries tiles that failed their HTTP request once', - value: retryFailedRequestTiles, - onChanged: (v) => context - .read() - .retryFailedRequestTiles = v, - ), - ], - ), + ), + ], + ), + const Divider(height: 24), + _ToggleOption( + icon: const Icon(Icons.file_copy), + title: 'Skip Existing Tiles', + description: "Don't attempt tiles that are already cached", + value: skipExistingTiles, + onChanged: (v) => context + .read() + .skipExistingTiles = v, + ), + const SizedBox(height: 8), + _ToggleOption( + icon: const Icon(Icons.waves), + title: 'Skip Sea Tiles', + description: + "Don't cache tiles with sea/ocean fill as the only visible " + 'element', + value: skipSeaTiles, + onChanged: (v) => + context.read().skipSeaTiles = v, + ), + const SizedBox(height: 8), + _ToggleOption( + icon: const Icon(Icons.plus_one), + title: 'Retry Failed Tiles', + description: 'Retries tiles that failed their HTTP request once', + value: retryFailedRequestTiles, + onChanged: (v) => context + .read() + .retryFailedRequestTiles = v, + ), + ], ); } } diff --git a/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart index 7f202016..e8b53127 100644 --- a/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart @@ -1,7 +1,13 @@ 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/bottom_sheet/components/scrollable_provider.dart'; import '../../layouts/bottom_sheet/utils/tab_header.dart'; +import 'components/config_options/config_options.dart'; +import 'components/confirmation_panel/confirmation_panel.dart'; class DownloadConfigurationViewBottomSheet extends StatelessWidget { const DownloadConfigurationViewBottomSheet({super.key}); @@ -10,9 +16,55 @@ class DownloadConfigurationViewBottomSheet extends StatelessWidget { Widget build(BuildContext context) => CustomScrollView( controller: BottomSheetScrollableProvider.innerScrollControllerOf(context), - slivers: const [ - TabHeader(title: 'Download Configuration'), - SliverToBoxAdapter(child: SizedBox(height: 6)), + slivers: [ + const TabHeader(title: 'Download Configuration'), + SliverToBoxAdapter( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(99), + ), + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(4), + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () { + 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), + label: const Text('Return to selection'), + ), + ), + ), + const SliverToBoxAdapter(child: Divider()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8) + + const EdgeInsets.symmetric(horizontal: 16), + child: const ConfigOptions(), + ), + ), + const SliverToBoxAdapter(child: Divider()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8) + + const EdgeInsets.symmetric(horizontal: 16), + child: const ConfirmationPanel(), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 8)), ], ); } diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart index 08036662..49b7d25b 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart @@ -7,21 +7,29 @@ import 'components/timing/timing.dart'; import 'components/title_bar/title_bar.dart'; class DownloadStatistics extends StatelessWidget { - const DownloadStatistics({super.key}); + const DownloadStatistics({ + super.key, + required this.showTitle, + }); + + final bool showTitle; @override - Widget build(BuildContext context) => const Column( + Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - TitleBar(), - SizedBox(height: 24), - TimingStats(), - SizedBox(height: 24), - ProgressIndicatorBars(), - SizedBox(height: 16), - ProgressIndicatorText(), - SizedBox(height: 24), - TileDisplay(), + if (showTitle) ...[ + const TitleBar(), + const SizedBox(height: 24), + ] else + const SizedBox(height: 6), + const TimingStats(), + const SizedBox(height: 24), + const ProgressIndicatorBars(), + const SizedBox(height: 16), + const ProgressIndicatorText(), + const SizedBox(height: 24), + const TileDisplay(), ], ); } diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart index 8b137891..97d8fadf 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart @@ -1 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../../../shared/state/download_provider.dart'; +import '../../../../../shared/state/region_selection_provider.dart'; +import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; +import '../../layouts/bottom_sheet/utils/tab_header.dart'; +import 'components/confirm_cancellation_dialog.dart'; +import 'components/statistics/statistics.dart'; +class DownloadingViewBottomSheet extends StatefulWidget { + const DownloadingViewBottomSheet({ + super.key, + }); + + @override + State createState() => + _DownloadingViewBottomSheetState(); +} + +class _DownloadingViewBottomSheetState + extends State { + bool _isPausing = false; + + @override + Widget build(BuildContext context) => CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + slivers: [ + if (context.select((p) => p.isComplete)) + const TabHeader(title: 'Download Complete') + else if (context.select((p) => p.isPaused)) + const TabHeader(title: 'Download Paused') + else + const TabHeader(title: 'Downloading Map'), + SliverToBoxAdapter( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(99), + ), + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(4) + + const EdgeInsets.symmetric(horizontal: 8), + child: context + .select((p) => p.isComplete) + ? Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: () { + context.read() + ..isDownloadSetupPanelVisible = false + ..clearConstructedRegions() + ..clearCoordinates(); + context.read().reset(); + }, + label: const Text('Reset'), + icon: const Icon(Icons.done_all), + ), + ) + : IntrinsicHeight( + child: Row( + children: [ + TextButton.icon( + onPressed: () async { + if (context + .read() + .isComplete) { + context.read() + ..isDownloadSetupPanelVisible = false + ..clearConstructedRegions() + ..clearCoordinates(); + context.read().reset(); + return; + } + + await showDialog( + context: context, + builder: (context) => + const ConfirmCancellationDialog(), + ); + }, + icon: const Icon(Icons.cancel), + label: const Text('Cancel'), + ), + const Spacer(), + if (context.select( + (p) => !p.isComplete, + )) + _isPausing + ? const AspectRatio( + aspectRatio: 1, + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator + .adaptive(), + ), + ), + ) + : context.select( + (p) => p.isPaused, + ) + ? TextButton.icon( + onPressed: () { + context + .read() + .resume(); + setState(() {}); + }, + icon: const Icon(Icons.play_arrow), + label: const Text('Resume'), + ) + : TextButton.icon( + onPressed: () async { + setState(() => _isPausing = true); + await context + .read() + .pause(); + setState(() => _isPausing = false); + }, + icon: const Icon(Icons.pause), + label: const Text('Pause'), + ), + ], + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8) + + const EdgeInsets.symmetric(horizontal: 16), + child: const DownloadStatistics(showTitle: false), + ), + ), + ], + ); +} diff --git a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart index 6495438d..4e6b0c63 100644 --- a/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart @@ -101,7 +101,7 @@ class _DownloadingViewSideState extends State { child: SingleChildScrollView( child: Padding( padding: EdgeInsets.all(16), - child: DownloadStatistics(), + child: DownloadStatistics(showTitle: true), ), ), ), diff --git a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/example_app_limitations_text.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/example_app_limitations_text.dart index 46832528..304e6fca 100644 --- a/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/example_app_limitations_text.dart +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/example_app_limitations_text.dart @@ -1,5 +1,6 @@ const exampleAppLimitationsText = 'There are some limitations to the example app which do not exist in FMTC, ' 'because it is difficult to express in this UI design.\nEach store only ' - 'contains tiles from a single URL template. Only a single tile layer is ' - 'used/available (only a single URL template can be used at any one time).'; + 'contains tiles from a single URL template. URL transformers are not ' + 'supported. Only a single tile layer is used/available (only a single URL ' + 'template can be used at any one time).'; diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart index 91c7dc07..fbce3ecd 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; import '../../layouts/bottom_sheet/components/scrollable_provider.dart'; import '../../layouts/bottom_sheet/utils/tab_header.dart'; +import 'components/shared/to_config_method.dart'; import 'components/sub_regions_list/components/no_sub_regions.dart'; import 'components/sub_regions_list/sub_regions_list.dart'; @@ -21,16 +22,47 @@ class RegionSelectionViewBottomSheet extends StatelessWidget { BottomSheetScrollableProvider.innerScrollControllerOf(context), slivers: [ const TabHeader(title: 'Download Selection'), + if (hasConstructedRegions) + const SubRegionsList() + else + const NoSubRegions(), const SliverToBoxAdapter(child: SizedBox(height: 6)), - SliverPadding( - padding: hasConstructedRegions - ? const EdgeInsets.only(bottom: 16 + 52) - : EdgeInsets.zero, - sliver: hasConstructedRegions - ? const SubRegionsList() - : const NoSubRegions(), + SliverFillRemaining( + hasScrollBody: false, + child: Align( + alignment: Alignment.bottomRight, + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(99), + ), + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => context + .read() + .clearConstructedRegions(), + icon: const Icon(Icons.delete_forever), + ), + const SizedBox(width: 8), + SizedBox( + height: double.infinity, + child: FilledButton.icon( + onPressed: () => prepareDownloadConfigView(context), + label: const Text('Configure Download'), + icon: const Icon(Icons.tune), + ), + ), + ], + ), + ), + ), + ), ), - const SliverToBoxAdapter(child: SizedBox(height: 6)), ], ); } 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 e4e36927..6da745d4 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 @@ -2,8 +2,10 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../../../../shared/state/download_provider.dart'; import '../../../../../shared/state/region_selection_provider.dart'; import '../../contents/download_configuration/download_configuration_view_bottom_sheet.dart'; +import '../../contents/downloading/downloading_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'; @@ -95,11 +97,15 @@ class _SecondaryViewBottomSheetState extends State { width: double.infinity, child: switch (widget.selectedTab) { 0 => const HomeViewBottomSheet(), - 1 => context.select( - (p) => p.isDownloadSetupPanelVisible, + 1 => context.select( + (p) => p.isFocused, ) - ? const DownloadConfigurationViewBottomSheet() - : const RegionSelectionViewBottomSheet(), + ? const DownloadingViewBottomSheet() + : context.select( + (p) => p.isDownloadSetupPanelVisible, + ) + ? const DownloadConfigurationViewBottomSheet() + : const RegionSelectionViewBottomSheet(), 2 => const RecoveryViewBottomSheet(), _ => Placeholder(key: ValueKey(widget.selectedTab)), }, diff --git a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart index 078f2621..950a6fd7 100644 --- a/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart @@ -86,7 +86,7 @@ class TabHeader extends StatelessWidget { return Row( children: [ SizedBox(width: calc(40), child: child), - SizedBox(width: calc(16)), + SizedBox(width: calc(8)), Text( title, style: Theme.of(context).textTheme.titleLarge, From d1cb12e5b18a5027f780edef224f1a0c3cee78fb Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 11 Jan 2025 17:29:51 +0000 Subject: [PATCH 93/97] Fixed minor bug in example app --- example/lib/src/screens/export/export.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/lib/src/screens/export/export.dart b/example/lib/src/screens/export/export.dart index 02dbaffb..331e003e 100644 --- a/example/lib/src/screens/export/export.dart +++ b/example/lib/src/screens/export/export.dart @@ -295,7 +295,7 @@ class _ExportPopupState extends State { final file = File(path); try { - await file.create(); + await file.create(recursive: true); await file.delete(); } on FileSystemException { return errorOut(); From 5539ef09edc1906962db257ccbb20fb2b156a619 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 11 Jan 2025 17:30:15 +0000 Subject: [PATCH 94/97] Prepare for v10 release --- CHANGELOG.md | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d367490..db299bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons # Changelog -## [10.0.0] - "Better Browsing" - 2025/XX/XX +## [10.0.0] - "Better Browsing" - 2025/01/11 This update builds on v9 to fully embrace the new many-to-many relationship between tiles and stores, which allows for more flexibility when constructing the `FMTCTileProvider`. This allows a new paradigm to be used: stores may now be treated as bulk downloaded regions, and all the regions/stores can be used at once - no more switching between them. This allows huge amounts of flexibility and a better UX in a complex application. Additionally, each store may now have its own `BrowseStoreStrategy` when browsing, which allows more flexibility: for example, stores may now contain more than one URL template/source, but control is retained. diff --git a/pubspec.yaml b/pubspec.yaml index 113f8a53..199e1fc6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 10.0.0-dev.7 +version: 10.0.0 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues From ea25d3c997c4193d93f58ff7860fd9a48e901e37 Mon Sep 17 00:00:00 2001 From: Luka S Date: Sat, 11 Jan 2025 17:30:55 +0000 Subject: [PATCH 95/97] Discard changes to example/.gitignore --- example/.gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/example/.gitignore b/example/.gitignore index 2a04e326..0fa6b675 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1,6 +1,3 @@ -# Custom -old_lib/ - # Miscellaneous *.class *.log From f0f713f580957805438699c54df72cfd11090931 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 11 Jan 2025 17:33:01 +0000 Subject: [PATCH 96/97] Discard changes to example/devtools_options.yaml --- example/devtools_options.yaml | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 example/devtools_options.yaml diff --git a/example/devtools_options.yaml b/example/devtools_options.yaml deleted file mode 100644 index fa0b357c..00000000 --- a/example/devtools_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: This file stores settings for Dart & Flutter DevTools. -documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states -extensions: From 43f4ec89f970678b5a792e84327cbd0e7e2af5db Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sat, 11 Jan 2025 19:47:28 +0000 Subject: [PATCH 97/97] Fixed bug where tile limit was always set to 200 in tile deletion logic regardless of set limit, meaning store max length was not respected correctly --- .../backend/internal_workers/standard/worker.dart | 12 +++++++----- .../image_provider/internal_tile_browser.dart | 2 ++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index c63b22de..bcbdc0db 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -110,14 +110,14 @@ Future _worker( final queriedStores = storesQuery.property(ObjectBoxStore_.name).find(); if (queriedStores.isEmpty) return 0; - final tileCount = - min(limitTiles ?? double.infinity, tilesQuery.count()); - if (tileCount == 0) return 0; + for (int offset = 0;; offset += tilesChunkSize) { + final limit = limitTiles == null + ? tilesChunkSize + : min(tilesChunkSize, limitTiles - offset); - for (int offset = 0; offset < tileCount; offset += tilesChunkSize) { final tilesChunk = (tilesQuery ..offset = offset - ..limit = tilesChunkSize) + ..limit = limit) .find(); // For each store, remove it from the tile if requested @@ -142,6 +142,8 @@ Future _worker( rootDeltaSize -= tile.bytes.lengthInBytes; tilesToRemove.add(tile.id); } + + if (tilesChunk.length < tilesChunkSize) break; } if (!hadTilesToUpdate && tilesToRemove.isEmpty) return 0; diff --git a/lib/src/providers/image_provider/internal_tile_browser.dart b/lib/src/providers/image_provider/internal_tile_browser.dart index 42665114..393242d2 100644 --- a/lib/src/providers/image_provider/internal_tile_browser.dart +++ b/lib/src/providers/image_provider/internal_tile_browser.dart @@ -241,6 +241,7 @@ Future _internalTileBrowser({ bytes: response.bodyBytes, ); currentTLIR?.storesWriteResult = writeOp; + unawaited( writeOp.then((result) { final createdIn = @@ -250,6 +251,7 @@ Future _internalTileBrowser({ // We only need to even attempt this if the number of tiles has changed if (createdIn.isEmpty) return; + // Internally debounced, so we don't need to debounce here FMTCBackendAccess.internal.removeOldestTilesAboveLimit( storeNames: createdIn.toList(growable: false), );