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/CHANGELOG.md b/CHANGELOG.md index 02eac826..db299bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,49 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons # Changelog +## [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. + +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. + +* Major changes to browse caching + * Added support for using multiple stores simultaneously in the `FMTCTileProvider` (through the `FMTCTileProvider.allStores` & `FMTCTileProvider.multipleStores` constructors) + * 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 + * 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` + * 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! + +* 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 + +* 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, and documentation) + +* Brand new example app to demonstrate the new levels of flexibility and customizability + ## [9.1.4] - 2024/12/05 * Fixed bug in `removeTilesOlderThan` where actually tiles newer than the specified expiry were being removed ([#172](https://github.com/JaffaKetchup/flutter_map_tile_caching/issues/172)) @@ -29,12 +72,12 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons ## [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 -* 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 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/.metadata b/example/.metadata index ddccdffc..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: "29babcb32a591b9e5be8c6a6075d4fe605d46ad3" + revision: "3e493a3e4d0a5c99fa7da51faae354e95a9a1abe" channel: "beta" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 29babcb32a591b9e5be8c6a6075d4fe605d46ad3 - base_revision: 29babcb32a591b9e5be8c6a6075d4fe605d46ad3 + create_revision: 3e493a3e4d0a5c99fa7da51faae354e95a9a1abe + base_revision: 3e493a3e4d0a5c99fa7da51faae354e95a9a1abe - platform: android - create_revision: 29babcb32a591b9e5be8c6a6075d4fe605d46ad3 - base_revision: 29babcb32a591b9e5be8c6a6075d4fe605d46ad3 + 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 64% rename from example/android/app/build.gradle rename to example/android/app/build.gradle.kts index 8e644b93..8e7323ff 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle.kts @@ -1,15 +1,15 @@ 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 { 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 @@ -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 3c85cfe0..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.7-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 d3bb611e..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.5.2' 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/lib/main.dart b/example/lib/main.dart index 2589a9f7..7ea60f76 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,29 +1,32 @@ 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/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/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/recoverable_regions_provider.dart'; +import 'src/shared/state/region_selection_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - ), - ); + + sharedPrefs = await SharedPreferences.getInstance(); 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; } @@ -38,18 +41,53 @@ class _AppContainer extends StatelessWidget { final Object? initialisationError; + static final _routes = { + MainScreen.route: ( + std: (BuildContext context) => const MainScreen(), + custom: null, + ), + StoreEditorPopup.route: ( + std: null, + custom: (context, settings) => MaterialPageRoute( + builder: (context) => const StoreEditorPopup(), + settings: settings, + fullscreenDialog: true, + ), + ), + ImportPopup.route: ( + std: null, + custom: (context, settings) => MaterialPageRoute( + builder: (context) => const ImportPopup(), + settings: settings, + fullscreenDialog: true, + ), + ), + ExportPopup.route: ( + std: null, + custom: (context, settings) => MaterialPageRoute( + builder: (context) => const ExportPopup(), + settings: settings, + fullscreenDialog: true, + ), + ), + }; + @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.green, 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, ), ), ); @@ -68,26 +106,32 @@ class _AppContainer extends StatelessWidget { create: (_) => GeneralProvider(), ), ChangeNotifierProvider( - create: (_) => MapProvider(), + create: (_) => RegionSelectionProvider(), lazy: true, ), ChangeNotifierProvider( - create: (_) => RegionSelectionProvider(), + create: (_) => DownloadConfigurationProvider(), lazy: true, ), ChangeNotifierProvider( - create: (_) => ConfigureDownloadProvider(), + create: (_) => DownloadingProvider(), lazy: true, ), ChangeNotifierProvider( - create: (_) => DownloadingProvider(), + create: (_) => RecoverableRegionsProvider(), lazy: true, ), ], child: MaterialApp( title: 'FMTC Demo', + restorationScopeId: 'FMTC Demo', theme: themeData, - home: const MainScreen(), + initialRoute: MainScreen.route, + onGenerateRoute: (settings) { + final route = _routes[settings.name]!; + if (route.custom != null) return route.custom!(context, 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 0c5c69cd..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 ?? 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/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 3f56aec0..00000000 --- a/example/lib/screens/main/main.dart +++ /dev/null @@ -1,157 +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) { - if (!mounted) return; - 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 fb7b5628..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/map/state/map_provider.dart b/example/lib/screens/main/pages/map/state/map_provider.dart deleted file mode 100644 index 0228b98e..00000000 --- a/example/lib/screens/main/pages/map/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/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/custom_polygon_snapping_indicator.dart b/example/lib/screens/main/pages/region_selection/components/custom_polygon_snapping_indicator.dart deleted file mode 100644 index cffb227a..00000000 --- a/example/lib/screens/main/pages/region_selection/components/custom_polygon_snapping_indicator.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:provider/provider.dart'; - -import '../state/region_selection_provider.dart'; - -class CustomPolygonSnappingIndicator extends StatelessWidget { - const CustomPolygonSnappingIndicator({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final coords = context - .select>((p) => p.coordinates); - - return MarkerLayer( - markers: [ - if (coords.isNotEmpty && - context.select( - (p) => p.customPolygonSnap, - )) - Marker( - height: 25, - width: 25, - point: coords.first, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(1028), - ), - child: const Center( - child: Icon(Icons.auto_awesome, size: 15), - ), - ), - ), - ], - ); - } -} diff --git a/example/lib/screens/main/pages/region_selection/components/region_shape.dart b/example/lib/screens/main/pages/region_selection/components/region_shape.dart deleted file mode 100644 index 770f8c4d..00000000 --- a/example/lib/screens/main/pages/region_selection/components/region_shape.dart +++ /dev/null @@ -1,98 +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:latlong2/latlong.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../shared/misc/region_type.dart'; -import '../state/region_selection_provider.dart'; - -class RegionShape extends StatelessWidget { - const RegionShape({ - super.key, - }); - - @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, - ], - 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.line: - throw Error(); - case RegionType.customPolygon: - holePoints = provider.isCustomPolygonComplete - ? provider.coordinates - : [ - ...provider.coordinates, - if (provider.customPolygonSnap) - provider.coordinates.first - else - provider.currentNewPointPos, - ]; - } - } - - return PolygonLayer( - polygons: [ - Polygon( - points: [ - const LatLng(-90, 180), - const LatLng(90, 180), - const LatLng(90, -180), - const LatLng(-90, -180), - ], - holePointsList: [holePoints], - borderColor: Colors.black, - borderStrokeWidth: 2, - color: Theme.of(context).colorScheme.surface.withOpacity(0.5), - ), - ], - ); - }, - ); -} diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/additional_pane.dart b/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/additional_pane.dart deleted file mode 100644 index 87033919..00000000 --- a/example/lib/screens/main/pages/region_selection/components/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/screens/main/pages/region_selection/components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart b/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart deleted file mode 100644 index c7458ea7..00000000 --- a/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart +++ /dev/null @@ -1,56 +0,0 @@ -part of '../parent.dart'; - -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/screens/main/pages/region_selection/components/side_panel/additional_panes/line_region_pane.dart b/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/line_region_pane.dart deleted file mode 100644 index 894773c1..00000000 --- a/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/line_region_pane.dart +++ /dev/null @@ -1,101 +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(); - - if (Platform.isAndroid || Platform.isIOS) { - await FilePicker.platform.clearTemporaryFiles(); - } - - 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 (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), - ); - } - } - }, - 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/screens/main/pages/region_selection/components/side_panel/additional_panes/slider_panel_base.dart b/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/slider_panel_base.dart deleted file mode 100644 index 3e8c1af8..00000000 --- a/example/lib/screens/main/pages/region_selection/components/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/screens/main/pages/region_selection/components/side_panel/custom_slider_track_shape.dart b/example/lib/screens/main/pages/region_selection/components/side_panel/custom_slider_track_shape.dart deleted file mode 100644 index e8f54d21..00000000 --- a/example/lib/screens/main/pages/region_selection/components/side_panel/custom_slider_track_shape.dart +++ /dev/null @@ -1,19 +0,0 @@ -part of 'parent.dart'; - -// From https://stackoverflow.com/a/65662764/11846040 -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/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/components/side_panel/primary_pane.dart b/example/lib/screens/main/pages/region_selection/components/side_panel/primary_pane.dart deleted file mode 100644 index 1c0fd3e5..00000000 --- a/example/lib/screens/main/pages/region_selection/components/side_panel/primary_pane.dart +++ /dev/null @@ -1,183 +0,0 @@ -part of 'parent.dart'; - -class _PrimaryPane extends StatelessWidget { - const _PrimaryPane({ - required this.constraints, - required this.layoutDirection, - required this.pushToConfigureDownload, - }); - - final BoxConstraints constraints; - final void Function() pushToConfigureDownload; - - final Axis layoutDirection; - - 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 - ? pushToConfigureDownload - : null, - ), - ], - ), - ), - ), - ], - ), - const SizedBox.square(dimension: 12), - _AdditionalPane( - constraints: constraints, - layoutDirection: layoutDirection, - ), - ], - ); -} diff --git a/example/lib/screens/main/pages/region_selection/components/side_panel/region_shape_button.dart b/example/lib/screens/main/pages/region_selection/components/side_panel/region_shape_button.dart deleted file mode 100644 index 7c9763f5..00000000 --- a/example/lib/screens/main/pages/region_selection/components/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/screens/main/pages/region_selection/components/usage_instructions.dart b/example/lib/screens/main/pages/region_selection/components/usage_instructions.dart deleted file mode 100644 index e3e90476..00000000 --- a/example/lib/screens/main/pages/region_selection/components/usage_instructions.dart +++ /dev/null @@ -1,109 +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 '../../../../../shared/misc/region_selection_method.dart'; -import '../../../../../shared/misc/region_type.dart'; -import '../state/region_selection_provider.dart'; - -class UsageInstructions extends StatelessWidget { - UsageInstructions({ - super.key, - required this.constraints, - }) : layoutDirection = - constraints.maxWidth > 1325 ? Axis.vertical : Axis.horizontal; - - final BoxConstraints constraints; - 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/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/region_selection/state/region_selection_provider.dart b/example/lib/screens/main/pages/region_selection/state/region_selection_provider.dart deleted file mode 100644 index fac3ec8d..00000000 --- a/example/lib/screens/main/pages/region_selection/state/region_selection_provider.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'dart:io'; - -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'; - -class RegionSelectionProvider extends ChangeNotifier { - RegionSelectionMethod _regionSelectionMethod = - Platform.isAndroid || Platform.isIOS - ? RegionSelectionMethod.useMapCenter - : RegionSelectionMethod.usePointer; - RegionSelectionMethod get regionSelectionMethod => _regionSelectionMethod; - set regionSelectionMethod(RegionSelectionMethod newMethod) { - _regionSelectionMethod = newMethod; - notifyListeners(); - } - - LatLng _currentNewPointPos = const LatLng(51.509364, -0.128928); - LatLng get currentNewPointPos => _currentNewPointPos; - set currentNewPointPos(LatLng newPos) { - _currentNewPointPos = newPos; - notifyListeners(); - } - - RegionType _regionType = RegionType.square; - RegionType get regionType => _regionType; - set regionType(RegionType newType) { - _regionType = newType; - notifyListeners(); - } - - BaseRegion? _region; - BaseRegion? get region => _region; - set region(BaseRegion? newRegion) { - _region = newRegion; - notifyListeners(); - } - - final List _coordinates = []; - List get coordinates => List.from(_coordinates); - List addCoordinate(LatLng coord) { - _coordinates.add(coord); - notifyListeners(); - return _coordinates; - } - - List addCoordinates(Iterable coords) { - _coordinates.addAll(coords); - notifyListeners(); - return _coordinates; - } - - void clearCoordinates() { - _coordinates.clear(); - _region = null; - notifyListeners(); - } - - void removeLastCoordinate() { - if (_coordinates.isNotEmpty) _coordinates.removeLast(); - if (_regionType == RegionType.customPolygon - ? !isCustomPolygonComplete - : _coordinates.length < 2) _region = null; - notifyListeners(); - } - - double _lineRadius = 100; - double get lineRadius => _lineRadius; - set lineRadius(double newNum) { - _lineRadius = newNum; - notifyListeners(); - } - - bool _customPolygonSnap = false; - bool get customPolygonSnap => _customPolygonSnap; - set customPolygonSnap(bool newState) { - _customPolygonSnap = newState; - 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) { - _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(); - } - - FMTCStore? _selectedStore; - FMTCStore? get selectedStore => _selectedStore; - void setSelectedStore(FMTCStore? newStore, {bool notify = true}) { - _selectedStore = newStore; - if (notify) notifyListeners(); - } -} 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/components/build_attribution.dart b/example/lib/shared/components/build_attribution.dart deleted file mode 100644 index 02f3ef1f..00000000 --- a/example/lib/shared/components/build_attribution.dart +++ /dev/null @@ -1,39 +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, - this.alignment = AttributionAlignment.bottomRight, - }); - - final String urlTemplate; - final AttributionAlignment alignment; - - @override - Widget build(BuildContext context) => RichAttributionWidget( - alignment: alignment, - popupInitialDisplayDuration: const Duration(seconds: 3), - popupBorderRadius: alignment == AttributionAlignment.bottomRight - ? null - : BorderRadius.circular(10), - 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/shared/components/loading_indicator.dart b/example/lib/shared/components/loading_indicator.dart deleted file mode 100644 index 99a47058..00000000 --- a/example/lib/shared/components/loading_indicator.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; - -class LoadingIndicator extends StatelessWidget { - const LoadingIndicator(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/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/export/export.dart b/example/lib/src/screens/export/export.dart new file mode 100644 index 00000000..331e003e --- /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(recursive: true); + 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 new file mode 100644 index 00000000..48444cfe --- /dev/null +++ b/example/lib/src/screens/import/import.dart @@ -0,0 +1,147 @@ +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, + ), + body: 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: 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..734efcfe --- /dev/null +++ b/example/lib/src/screens/import/stages/selection.dart @@ -0,0 +1,133 @@ +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), + Expanded(child: Text(text)), + ], + ), + ); + }, + ).toList(growable: false), + onChanged: (c) => setState(() => conflictStrategy = c!), + ), + ), + const SizedBox(width: 16), + 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/screens/initialisation_error/initialisation_error.dart b/example/lib/src/screens/initialisation_error/initialisation_error.dart similarity index 79% rename from example/lib/screens/initialisation_error/initialisation_error.dart rename to example/lib/src/screens/initialisation_error/initialisation_error.dart index 7cae2a7b..cb579264 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,46 +13,39 @@ 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, 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! - .copyWith(color: Colors.white), + style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center, ), - const SizedBox(height: 32), + 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, + style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), const SizedBox(height: 32), - 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), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - OutlinedButton( + FilledButton( onPressed: () async { void showFailure() { if (context.mounted) { @@ -85,12 +78,11 @@ class InitialisationError extends StatelessWidget { await dir.delete(recursive: true); } on FileSystemException { showFailure(); - rethrow; + return; } - 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/src/screens/main/layouts/horizontal.dart b/example/lib/src/screens/main/layouts/horizontal.dart new file mode 100644 index 00000000..840170b1 --- /dev/null +++ b/example/lib/src/screens/main/layouts/horizontal.dart @@ -0,0 +1,190 @@ +part of '../main.dart'; + +class _HorizontalLayout extends StatefulWidget { + const _HorizontalLayout({ + required DraggableScrollableController bottomSheetOuterController, + required this.mapMode, + required this.selectedTab, + }) : _bottomSheetOuterController = bottomSheetOuterController; + + final DraggableScrollableController _bottomSheetOuterController; + 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) { + 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'), + ), + 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: 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; + if (!_isSecondaryViewUserExpanded) { + setState(() => _isSecondaryViewUserExpanded = true); + } + }, + ), + 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: 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( + 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/layouts/vertical.dart b/example/lib/src/screens/main/layouts/vertical.dart new file mode 100644 index 00000000..517d64f8 --- /dev/null +++ b/example/lib/src/screens/main/layouts/vertical.dart @@ -0,0 +1,113 @@ +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.select( + (provider) => + provider.constructedRegions.isNotEmpty && + !provider.isDownloadSetupPanelVisible, + ) + ? DelayedControllerAttachmentBuilder( + listenable: _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 new file mode 100644 index 00000000..3380cc19 --- /dev/null +++ b/example/lib/src/screens/main/main.dart @@ -0,0 +1,112 @@ +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'; +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'; +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}); + + static const String route = '/'; + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + 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)], + ), + ); + }, + ), + ); + }, + ); + } + + @override + void dispose() { + _failedRegionsStreamSub?.cancel(); + super.dispose(); + } + + @override + 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 < 640 ? Axis.vertical : Axis.horizontal; + + if (layoutDirection == Axis.vertical) { + return _VerticalLayout( + bottomSheetOuterController: _bottomSheetOuterController, + mapMode: mapMode, + selectedTab: selectedTab, + constrainedHeight: constraints.maxHeight, + ); + } + + return _HorizontalLayout( + bottomSheetOuterController: _bottomSheetOuterController, + mapMode: mapMode, + selectedTab: selectedTab, + ); + }, + ); + }, + ); +} 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..5def22c1 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/additional_overlay/additional_overlay.dart @@ -0,0 +1,127 @@ +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 _HeightZero( + useChildHeight: showShapeSelector && + bottomSheetOuterController.pixels <= 33, + 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..c493e5fc --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/additional_overlay/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) => 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'), + ], + ], + ), + ), + ), + ), + ), + ); +} 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/components/bottom_sheet_wrapper.dart b/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart new file mode 100644 index 00000000..26164938 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/bottom_sheet_wrapper.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import '../../secondary_view/layouts/bottom_sheet/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 = + MediaQueryData.fromView(View.of(context)).padding.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, + bottomSheetOuterController: widget.bottomSheetOuterController, + 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/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 new file mode 100644 index 00000000..87a3013d --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/debugging_tile_builder.dart @@ -0,0 +1,55 @@ +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 StatefulWidget { + const DebuggingTileBuilder({ + super.key, + required this.tileWidget, + required this.tile, + required this.tileLoadingDebugger, + }); + + final Widget tileWidget; + final TileImage tile; + final ValueNotifier tileLoadingDebugger; + + @override + State createState() => _DebuggingTileBuilderState(); +} + +class _DebuggingTileBuilderState extends State { + @override + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Colors.black.withValues(alpha: 0.8), + width: 2, + ), + color: Colors.white.withValues(alpha: 0.5), + ), + position: DecorationPosition.foreground, + child: widget.tileWidget, + ), + 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(), + ); + }, + ), + ], + ); +} 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 new file mode 100644 index 00000000..db6d6b8a --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/info_display.dart @@ -0,0 +1,95 @@ +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?.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: [ + 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( + future: fmtcResult.storesWriteResult, + 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/debugging_tile_builder/result_dialogs.dart b/example/lib/src/screens/main/map_view/components/debugging_tile_builder/result_dialogs.dart new file mode 100644 index 00000000..12f75ba2 --- /dev/null +++ b/example/lib/src/screens/main/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/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 new file mode 100644 index 00000000..1304f6b7 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/download_progress/components/render_object.dart @@ -0,0 +1,472 @@ +part of '../download_progress_masker.dart'; + +class DownloadProgressMaskerRenderObject extends SingleChildRenderObjectWidget { + const DownloadProgressMaskerRenderObject({ + super.key, + 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; + final int maxZoom; + final int tileSize; + + @override + RenderObject createRenderObject(BuildContext context) => + _DownloadProgressMaskerRenderer( + isVisible: isVisible, + mapCamera: mapCamera, + minZoom: minZoom, + maxZoom: maxZoom, + tileSize: tileSize, + ); + + @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, + ) { + 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 _DownloadProgressMaskerRenderer extends RenderProxyBox { + _DownloadProgressMaskerRenderer({ + required bool isVisible, + required MapCamera mapCamera, + required this.minZoom, + required this.maxZoom, + required this.tileSize, + }) : assert( + maxZoom - minZoom < 32, + 'Unable to work with the large numbers that result from handling the ' + 'difference of `maxZoom` & `minZoom`', + ), + _mapCamera = mapCamera, + _isVisible = isVisible { + // Precalculate for more efficient greyscale amount calculations later + _maxSubtilesCountPerZoomLevel = Uint64List((maxZoom - minZoom) + 1); + int p = 0; + for (int i = minZoom; i < maxZoom; i++) { + _maxSubtilesCountPerZoomLevel[p] = pow(4, maxZoom - i).toInt(); + p++; + } + _maxSubtilesCountPerZoomLevel[p] = 0; + } + + //! 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; + _recompileEffectLevelPathCache(); + 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; + + /// 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) & + /// 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 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 _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 + late final Uint64List _maxSubtilesCountPerZoomLevel; + + /// 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/[_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 _effectLevelsCount = 25; + + //! 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), + (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, + ]); + } + + /// Calculate the greyscale level given the number of subtiles actually + /// downloaded and the possible number of subtiles + /// + /// Multiply by 1/[_effectLevelsCount] to pass to [_generateGreyscaleFilter] + /// to generate [ColorFilter]. + int _calculateEffectLevel(int subtilesCount, int maxSubtilesCount) { + assert( + subtilesCount <= maxSubtilesCount, + '`subtilesCount` must be less than or equal to `maxSubtilesCount`', + ); + + final invGreyscalePercentage = + (subtilesCount + 1) / (maxSubtilesCount + 1); // +1 to count self + return _effectLevelsCount - + (invGreyscalePercentage * _effectLevelsCount).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, + 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, + ); + } + + /// 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 [_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 [_recompileEffectLevelPathCache] + /// for details. + 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, + (intermediateZoomTile) { + final maxSubtilesCount = + _maxSubtilesCountPerZoomLevel[intermediateZoomTile.z - minZoom]; + + final _TileMappingValue tmv; + if (_tileMapping[intermediateZoomTile] case final existingTMV?) { + assert( + existingTMV.subtilesCount < maxSubtilesCount, + 'Existing subtiles count must be smaller than max subtiles count ' + '($intermediateZoomTile: ${existingTMV.subtilesCount} !< ' + '$maxSubtilesCount)', + ); + + existingTMV.subtilesCount += 1; + tmv = existingTMV; + } else { + 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), + ); + _mostRecentTile = + () => _calculateRectOfCoords(tmv.nwCoord, tmv.seCoord); + } + + _effectLevelPathCache[ + _calculateEffectLevel(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) { + // Remove adjacent tiles in quad + _tileMapping + ..remove(intermediateZoomTile) // self + ..remove( + TileCoordinates( + intermediateZoomTile.x + + (intermediateZoomTile.x.isOdd ? -1 : 1), + intermediateZoomTile.y, + intermediateZoomTile.z, + ), + ) + ..remove( + TileCoordinates( + intermediateZoomTile.x, + intermediateZoomTile.y + + (intermediateZoomTile.y.isOdd ? -1 : 1), + intermediateZoomTile.z, + ), + ) + ..remove( + TileCoordinates( + intermediateZoomTile.x + + (intermediateZoomTile.x.isOdd ? -1 : 1), + intermediateZoomTile.y + + (intermediateZoomTile.y.isOdd ? -1 : 1), + intermediateZoomTile.z, + ), + ); + } + }, + ); + + markNeedsPaint(); + } + + /// Recompile the [_effectLevelPathCache] 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 [addTile] + /// cannot prune the path cache, only the tile mapping). + /// + /// This method does not call [markNeedsPaint], the caller should perform that + /// if necessary. + void _recompileEffectLevelPathCache() { + for (final path in _effectLevelPathCache.values) { + path.reset(); + } + + for (final MapEntry( + key: TileCoordinates(z: tileZoom), + value: _TileMappingValue(:subtilesCount, :nwCoord, :seCoord), + ) in _tileMapping.entries) { + _effectLevelPathCache[_calculateEffectLevel( + subtilesCount, + _maxSubtilesCountPerZoomLevel[tileZoom - minZoom], + )]! + .addRect(_calculateRectOfCoords(nwCoord, seCoord)); + } + } + + //! 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) { + if (!isVisible) return super.paint(context, offset); + + // Paint the map in full greyscale & blur + context.pushColorFilter( + offset, + _generateGreyscaleFilter(1), + (context, offset) => context.pushImageFilter( + offset, + ImageFilter.blur(sigmaX: _maxBlurSigma, sigmaY: _maxBlurSigma), + (context, offset) => context.paintChild(child!, offset), + ), + ); + + // 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) = + _effectLevelPathCache.entries.elementAt(i); + + final effectPercentage = effectLevel / _effectLevelsCount; + + _layerHandles.elementAt(layerHandleIndex).layer = context.pushColorFilter( + offset, + _generateGreyscaleFilter(effectPercentage), + (context, offset) => context.pushImageFilter( + offset, + 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 [_DownloadProgressMaskerRenderer._tileMapping] for documentation +/// +/// Is mutable to improve performance. +class _TileMappingValue { + _TileMappingValue.newTile({ + required this.nwCoord, + required this.seCoord, + }) : subtilesCount = 0; + + int subtilesCount; + + 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 new file mode 100644 index 00000000..597cc019 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart @@ -0,0 +1,65 @@ +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'; +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/render_object.dart'; + +class DownloadProgressMasker extends StatefulWidget { + const DownloadProgressMasker({ + super.key, + required this.isVisible, + required this.tileEvents, + required this.minZoom, + required this.maxZoom, + this.tileSize = 256, + 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) => RepaintBoundary( + child: StreamBuilder( + stream: widget.tileEvents + ?.where( + (evt) => evt is SuccessfulTileEvent || evt is SkippedTileEvent, + ) + .map((evt) => evt.coordinates), + builder: (context, coords) => DownloadProgressMaskerRenderObject( + isVisible: widget.isVisible, + mapCamera: MapCamera.of(context), + latestTileCoordinates: coords.data == null + ? null + : TileCoordinates( + coords.requireData.$1, + coords.requireData.$2, + coords.requireData.$3, + ), + minZoom: widget.minZoom, + maxZoom: widget.maxZoom, + tileSize: widget.tileSize, + child: widget.child, + ), + ), + ); +} 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/screens/main/pages/region_selection/components/crosshairs.dart b/example/lib/src/screens/main/map_view/components/region_selection/crosshairs.dart similarity index 100% rename from example/lib/screens/main/pages/region_selection/components/crosshairs.dart rename to example/lib/src/screens/main/map_view/components/region_selection/crosshairs.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 new file mode 100644 index 00000000..5be14bf2 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/region_selection/custom_polygon_snapping_indicator.dart @@ -0,0 +1,39 @@ +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/region_selection_provider.dart'; + +class CustomPolygonSnappingIndicator extends StatelessWidget { + 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 && customPolygonSnap) + Marker( + height: 32, + width: 32, + point: coords.first, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(width: 2), + ), + 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 new file mode 100644 index 00000000..ec094739 --- /dev/null +++ b/example/lib/src/screens/main/map_view/components/region_selection/region_shape.dart @@ -0,0 +1,161 @@ +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/state/download_provider.dart'; +import '../../../../../shared/state/general_provider.dart'; +import '../../../../../shared/state/region_selection_provider.dart'; + +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, _) { + final ccc = provider.currentConstructingCoordinates; + final cnpp = provider.currentNewPointPos ?? + context + .watch() + .animatedMapController + .mapController + .camera + .center; + + 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, + ], + _ => 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: const [ + LatLng(-90, 180), + LatLng(90, 180), + LatLng(90, -180), + LatLng(-90, -180), + ], + holePointsList: renderConstructingRegion, + borderColor: Colors.black, + borderStrokeWidth: 2, + color: Theme.of(context) + .colorScheme + .surface + .withAlpha(255 ~/ 2), + ), + ], + ), + ], + ); + }, + ); + + 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 new file mode 100644 index 00000000..a2bfe56b --- /dev/null +++ b/example/lib/src/screens/main/map_view/map_view.dart @@ -0,0 +1,444 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +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'; +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 '../../../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 '../../../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'; +import 'components/region_selection/crosshairs.dart'; +import 'components/region_selection/custom_polygon_snapping_indicator.dart'; +import 'components/region_selection/region_shape.dart'; + +enum MapViewMode { + standard, + downloadRegion, + recovery, +} + +class MapView extends StatefulWidget { + const MapView({ + super.key, + 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(); +} + +class _MapViewState extends State with TickerProviderStateMixin { + late final _httpClient = IOClient(HttpClient()..userAgent = null); + late final _mapController = AnimatedMapController( + vsync: this, + curve: Curves.easeInOut, + ); + + final _tileLoadingDebugger = ValueNotifier({}); + + late final _storesStream = + FMTCRoot.stats.watchStores(triggerImmediately: true).asyncMap( + (_) async { + final stores = await FMTCRoot.stats.storesAvailable; + + return { + for (final store in stores) + store.storeName: await store.metadata.read + .then((e) => e[StoreMetadataKeys.urlTemplate.key]), + }; + }, + ).distinct(mapEquals); + + bool get _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, + 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 (!_isInRegionSelectMode) return; + + final provider = context.read(); + + final newPoint = provider.currentNewPointPos ?? + _mapController.mapController.camera.center; + + switch (provider.currentRegionType) { + case RegionType.rectangle: + final coords = provider.addCoordinate(newPoint); + + if (coords.length == 2) { + final region = RectangleRegion(LatLngBounds.fromPoints(coords)); + provider.addConstructedRegion(region); + } + case RegionType.circle: + final coords = provider.addCoordinate(newPoint); + + if (coords.length == 2) { + final region = CircleRegion( + coords[0], + const Distance(roundResult: false) + .distance(coords[0], coords[1]) / + 1000, + ); + provider.addConstructedRegion(region); + } + case RegionType.line: + provider.addCoordinate(newPoint); + case RegionType.customPolygon: + 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: (_, __) { + if (!_isInRegionSelectMode) return; + context.read().removeLastCoordinate(); + }, + onLongPress: (_, __) { + if (!_isInRegionSelectMode) return; + context.read().removeLastCoordinate(); + }, + onPointerHover: (evt, point) { + if (!_isInRegionSelectMode) return; + + final provider = context.read(); + + if (provider.regionSelectionMethod == + RegionSelectionMethod.usePointer) { + provider.currentNewPointPos = point; + + if (provider.currentRegionType == RegionType.customPolygon) { + final coords = provider.currentConstructingCoordinates; + 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 (!_isInRegionSelectMode) return; + + final provider = context.read(); + + if (provider.regionSelectionMethod == + RegionSelectionMethod.useMapCenter) { + provider.currentNewPointPos = position.center; + + if (provider.currentRegionType == RegionType.customPolygon) { + final coords = provider.currentConstructingCoordinates; + 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; + } + } + } + }, + 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, + ); + } + }, + onMapReady: () { + context.read().animatedMapController = _mapController; + }, + ); + + return StreamBuilder( + stream: _storesStream, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const AbsorbPointer( + 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!; + + return Consumer( + 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) { + final internalBehaviour = provider.currentStores[e.key]; + final behaviour = internalBehaviour == null + ? provider.inheritableBrowseStoreStrategy + : internalBehaviour.toBrowseStoreStrategy( + provider.inheritableBrowseStoreStrategy, + ); + if (behaviour == null) return null; + return MapEntry(e.key, behaviour); + }).nonNulls, + ...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 = Attribution( + urlTemplate: urlTemplate, + mode: widget.mode, + stores: compiledStoreNames, + otherStoresStrategy: otherStoresStrategy, + ); + + final tileLayer = TileLayer( + urlTemplate: urlTemplate, + userAgentPackageName: 'dev.jaffaketchup.fmtc.demo', + maxNativeZoom: 20, + tileProvider: widget.mode != MapViewMode.standard + ? NetworkTileProvider() + : FMTCTileProvider( + stores: compiledStoreNames, + otherStoresStrategy: otherStoresStrategy, + loadingStrategy: provider.loadingStrategy, + useOtherStoresAsFallbackOnly: + provider.useUnspecifiedAsFallbackOnly, + recordHitsAndMisses: false, + tileLoadingInterceptor: _tileLoadingDebugger, + httpClient: _httpClient, + // This is the intended purpose + // 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, + ), + ); + + final isDownloadProgressMaskerVisible = widget.mode == + MapViewMode.downloadRegion && + context.select((p) => p.isFocused); + + final map = FlutterMap( + key: ValueKey(selectedTabState.value), + mapController: _mapController.mapController, + options: mapOptions, + children: [ + 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, + ), + child: tileLayer, + ), + if (widget.mode == MapViewMode.downloadRegion) ...[ + const RegionShape(), + const CustomPolygonSnappingIndicator(), + ], + if (widget.mode == MapViewMode.recovery) + const RecoveryRegions(), + if (widget.bottomPaddingWrapperBuilder case final bpwb?) + Builder(builder: (context) => bpwb(context, attribution)) + else + attribution, + ], + ); + + return Stack( + fit: StackFit.expand, + children: [ + MouseRegion( + opaque: false, + 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, + MapViewMode.downloadRegion => SystemMouseCursors.precise, + }, + child: map, + ), + if (isCrosshairsVisible) const Center(child: Crosshairs()), + 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/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..07691c0a --- /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: 80, + child: Text( + '$value $descriptor', + textAlign: TextAlign.end, + ), + ), + ], + ); +} 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..5f9e4e56 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/store_selector.dart @@ -0,0 +1,104 @@ +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, + this.enabled = true, + }); + + final String? storeName; + final void Function(String?) onStoreNameSelected; + final bool enabled; + + @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, + ), + enabled: widget.enabled, + ); + }, + ), + ), + ); + + 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/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..36007d5e --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/components/toggle_option.dart @@ -0,0 +1,44 @@ +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; + // Parameter meaning obvious from context, also callback + // 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 new file mode 100644 index 00000000..93c07e36 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/config_options/config_options.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +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}); + + @override + State createState() => _ConfigOptionsState(); +} + +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 = + 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); + final skipExistingTiles = + context.select( + (p) => p.skipExistingTiles, + ); + final skipSeaTiles = context + .select((p) => p.skipSeaTiles); + final retryFailedRequestTiles = + context.select( + (p) => p.retryFailedRequestTiles, + ); + final fromRecovery = context + .select((p) => p.fromRecovery); + + 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')}', + ), + ], + ), + 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(), + ), + ), + 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, + ), + ], + ); + } +} 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..aecaeec4 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/components/confirmation_panel/confirmation_panel.dart @@ -0,0 +1,274 @@ +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: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 { + const ConfirmationPanel({super.key}); + + @override + State createState() => _ConfirmationPanelState(); +} + +class _ConfirmationPanelState extends State { + DownloadableRegion? _prevTileCountableRegion; + late Future _tileCount; + + 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 = + context.select((p) => p.endTile); + final hasSelectedStoreName = + context.select( + (p) => p.selectedStoreName, + ) != + null; + final fromRecovery = context.select( + (p) => p.fromRecovery, + ); + + final tileCountableRegion = MultiRegion(regions).toDownloadable( + minZoom: minZoom, + maxZoom: maxZoom, + start: startTile, + end: endTile, + options: TileLayer(), + ); + 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(); + } + + 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.requireData), + 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, + 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, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 46, + width: double.infinity, + child: FilledButton.icon( + 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), + ), + ), + if (fromRecovery != null) ...[ + const SizedBox(height: 4), + Text( + 'This will delete the recoverable region', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ], + ), + ); + } + + void _updateTileCount() { + _tileCount = + const FMTCStore('').download.countTiles(_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 downloadStreams = store.download.startForeground( + region: downloadableRegion, + parallelThreads: downloadConfiguration.parallelThreads, + maxBufferLength: downloadConfiguration.maxBufferLength, + skipExistingTiles: downloadConfiguration.skipExistingTiles, + skipSeaTiles: downloadConfiguration.skipSeaTiles, + retryFailedRequestTiles: downloadConfiguration.retryFailedRequestTiles, + rateLimit: downloadConfiguration.rateLimit, + ); + + 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; + } + + // 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_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..e8b53127 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_bottom_sheet.dart @@ -0,0 +1,70 @@ +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}); + + @override + Widget build(BuildContext context) => CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + 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/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..db1d7c31 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/download_configuration/download_configuration_view_side.dart @@ -0,0 +1,63 @@ +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'; + +class DownloadConfigurationViewSide extends StatelessWidget { + const DownloadConfigurationViewSide({super.key}); + + @override + 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: () { + 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', + ), + ), + ), + const SizedBox(height: 16), + const Expanded( + child: SideViewPanel( + autoPadding: false, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16), + child: ConfigOptions(), + ), + ), + ), + ), + const SizedBox(height: 16), + const SideViewPanel(child: ConfirmationPanel()), + const SizedBox(height: 16), + ], + ); +} 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..592ec305 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/colors.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +class DownloadingProgressIndicatorColors { + static final pendingColor = Colors.grey[350]!; + static const failedColor = Colors.red; + 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 new file mode 100644 index 00000000..c4f6154a --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_bars.dart @@ -0,0 +1,69 @@ +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.latestDownloadProgress.successfulTilesCount / + p.latestDownloadProgress.maxTilesCount, + ); + final skipped = context.select( + (p) => + p.latestDownloadProgress.skippedTilesCount / + p.latestDownloadProgress.maxTilesCount, + ); + final failed = context.select( + (p) => + p.latestDownloadProgress.failedTilesCount / + p.latestDownloadProgress.maxTilesCount, + ); + final retryQueue = context.select( + (p) => + p.latestDownloadProgress.retryTilesQueuedCount / + p.latestDownloadProgress.maxTilesCount, + ); + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: IntrinsicHeight( + child: Stack( + children: [ + LinearProgressIndicator( + 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, + 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..7e8910c3 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/progress/indicator_text.dart @@ -0,0 +1,261 @@ +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 { + bool _usePercentages = false; + + @override + Widget build(BuildContext context) { + final successfulFlushedTilesCount = + context.select( + (p) => p.latestDownloadProgress.flushedTilesCount, + ); + 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 failedNegativeResponseTilesCount = + context.select( + (p) => p.latestDownloadProgress.negativeResponseTilesCount, + ); + + final failedFailedRequestTilesCount = + context.select( + (p) => p.latestDownloadProgress.failedRequestTilesCount, + ); + + final retryTilesQueuedCount = context.select( + (p) => p.latestDownloadProgress.retryTilesQueuedCount, + ); + + final remainingTilesCount = context.select( + (p) => p.latestDownloadProgress.remainingTilesCount, + ) - + retryTilesQueuedCount; + + final maxTilesCount = context.select( + (p) => p.latestDownloadProgress.maxTilesCount, + ); + + return Column( + children: [ + 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, + ), + ], + ), + ), + ), + 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 + ? '''${(((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})''', + ), + const SizedBox(height: 4), + _TextRow( + type: 'Buffered', + statistic: _usePercentages + ? '''${((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})''', + ), + 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 + ? '''${(((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 + ? '''${((remainingTilesCount / maxTilesCount) * 100).toStringAsFixed(1)}%''' + : '$remainingTilesCount/$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/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..b4eb1471 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/tile_display/tile_display.dart @@ -0,0 +1,91 @@ +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 TileDisplay extends StatelessWidget { + const TileDisplay({super.key}); + + static const _dimension = 200.0; + + @override + Widget build(BuildContext context) { + 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: [ + 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.broken_image, + 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, + 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), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} 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..1af127cf --- /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.latestDownloadProgress.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.latestDownloadProgress.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.latestDownloadProgress.tilesPerSecond, + ) + .toStringAsFixed(0), + style: Theme.of(context).textTheme.titleLarge, + ), + if (context.select( + (p) => p.latestDownloadProgress.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/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..7448dc3d --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/components/title_bar/title_bar.dart @@ -0,0 +1,69 @@ +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.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 new file mode 100644 index 00000000..49b7d25b --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/components/statistics/statistics.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.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, + required this.showTitle, + }); + + final bool showTitle; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 new file mode 100644 index 00000000..97d8fadf --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_bottom_sheet.dart @@ -0,0 +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 new file mode 100644 index 00000000..4e6b0c63 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/downloading/downloading_view_side.dart @@ -0,0 +1,112 @@ +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().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.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( + autoPadding: false, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16), + child: DownloadStatistics(showTitle: true), + ), + ), + ), + ), + const SizedBox(height: 16), + ], + ); +} 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 new file mode 100644 index 00000000..a4b453d4 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/map_configurator/components/loading_behaviour_selector.dart @@ -0,0 +1,61 @@ +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'; + +class LoadingBehaviourSelector extends StatelessWidget { + const LoadingBehaviourSelector({ + super.key, + }); + + @override + Widget build(BuildContext context) => + 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( + segments: const [ + ButtonSegment( + value: BrowseLoadingStrategy.cacheOnly, + icon: Icon(Icons.download_for_offline_outlined), + label: Text('Cache Only'), + ), + ButtonSegment( + value: BrowseLoadingStrategy.cacheFirst, + icon: Icon(Icons.storage_rounded), + label: Text('Cache First'), + ), + ButtonSegment( + value: BrowseLoadingStrategy.onlineFirst, + icon: Icon(Icons.public_rounded), + label: Text('Online First'), + ), + ], + selected: {loadingStrategy}, + onSelectionChanged: (value) => context + .read() + .loadingStrategy = value.single, + style: const ButtonStyle( + visualDensity: VisualDensity.comfortable, + ), + ), + ), + const SizedBox(height: 6), + ], + ), + ); +} 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..1213004f --- /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_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 new file mode 100644 index 00000000..03885b65 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/column_headers_and_inheritable_settings.dart @@ -0,0 +1,158 @@ +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, + required this.useCompactLayout, + }); + + final bool useCompactLayout; + + @override + Widget build(BuildContext context) => Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 28) + + (useCompactLayout + ? const EdgeInsets.only(right: 32) + : EdgeInsets.zero), + child: IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Tooltip( + message: 'Inherit', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.settings_suggest), + ), + ), + const VerticalDivider(width: 2), + if (useCompactLayout) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.settings), + ) + else ...[ + const Tooltip( + message: 'Read only', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.visibility), + ), + ), + const Tooltip( + message: ' + update existing', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.edit), + ), + ), + const Tooltip( + message: ' + create new', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.add), + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 8), + 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.close), + 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 + .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/example_app_limitations_text.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/example_app_limitations_text.dart new file mode 100644 index 00000000..304e6fca --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/example_app_limitations_text.dart @@ -0,0 +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. 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/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 new file mode 100644 index 00000000..7294ef4c --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/new_store_button.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import '../../../../../../../export/export.dart'; +import '../../../../../../../import/import.dart'; +import '../../../../../../../store_editor/store_editor.dart'; +import 'example_app_limitations_text.dart'; + +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: [ + 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), + onPressed: () => Navigator.of(context) + .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: () => Navigator.of(context) + .pushNamed(ExportPopup.route), + 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), + ), + secondChild: IconButton( + onPressed: () => + setState(() => _showingImportExportButtons = false), + icon: const Icon(Icons.close), + ), + ), + ], + ), + const SizedBox(height: 24), + Text( + 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 new file mode 100644 index 00000000..41b9278a --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/no_stores.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import '../../../../../../../import/import.dart'; +import '../../../../../../../store_editor/store_editor.dart'; +import 'example_app_limitations_text.dart'; + +class NoStores extends StatelessWidget { + const NoStores({ + super.key, + required this.newStoreName, + }); + + final void Function(String) newStoreName; + + @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( + height: 42, + width: double.infinity, + child: FilledButton.icon( + 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'), + ), + ), + const SizedBox(height: 6), + SizedBox( + height: 42, + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => ImportPopup.start(context), + icon: const Icon(Icons.file_open), + label: const Text('Import a store'), + ), + ), + const SizedBox(height: 32), + Text( + 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/root_tile.dart b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/root_tile.dart new file mode 100644 index 00000000..172300fa --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/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.storage_rounded, 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/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 new file mode 100644 index 00000000..0b6599b5 --- /dev/null +++ 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 @@ -0,0 +1,195 @@ +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; + static const _unspecifiedSelectorExcludedColor = Colors.purple; + + @override + Widget build(BuildContext context) { + final currentStrategy = + context.select( + (provider) => provider.currentStores[storeName], + ); + final unspecifiedStrategy = context + .select( + (provider) => provider.currentStores['(unspecified)'], + ) + ?.toBrowseStoreStrategy(); + final inheritableStrategy = inheritable + ? context.select( + (provider) => provider.inheritableBrowseStoreStrategy, + ) + : null; + + final resolvedCurrentStrategy = currentStrategy == null + ? inheritableStrategy + : currentStrategy.toBrowseStoreStrategy(inheritableStrategy); + final isUsingUnselectedStrategy = resolvedCurrentStrategy == null && + 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) { + final provider = context.read(); + + provider + ..currentStores[storeName] = value! + ? InternalBrowseStoreStrategy.inherit + : InternalBrowseStoreStrategy.fromBrowseStoreStrategy( + provider.inheritableBrowseStoreStrategy, + ) + ..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, + 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)', + isExplicitlyExcluded: isExplicitlyExcluded, + ) + else + Stack( + children: [ + Transform.translate( + offset: const Offset(2, 0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + .withAlpha(255 ~/ 2) + : BrowseStoreStrategySelector._unspecifiedSelectorColor + .withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(99), + ), + width: isUsingUnselectedStrategy + ? switch (unspecifiedStrategy) { + BrowseStoreStrategy.read => 40, + BrowseStoreStrategy.readUpdate => 85, + BrowseStoreStrategy.readUpdateCreate => 128, + } + : 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/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 new file mode 100644 index 00000000..2a1ad6af --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/checkbox.dart @@ -0,0 +1,65 @@ +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 && + !isUnspecifiedSelector) { + // 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/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 new file mode 100644 index 00000000..55b6fbad --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/store_tile/components/browse_store_strategy_selector/dropdown.dart @@ -0,0 +1,113 @@ +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.isExplicitlyExcluded, + }); + + final String storeName; + final BrowseStoreStrategy? currentStrategy; + final bool enabled; + final bool isUnspecifiedSelector; + final bool isExplicitlyExcluded; + + @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 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 => Icon( + Icons.visibility, + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + : BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + InternalBrowseStoreStrategy.readUpdate => Icon( + Icons.edit, + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + : BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + InternalBrowseStoreStrategy.readUpdateCreate => Icon( + Icons.add, + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + : BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + _ => Icon( + Icons.disabled_by_default_rounded, + color: isExplicitlyExcluded + ? BrowseStoreStrategySelector + ._unspecifiedSelectorExcludedColor + : BrowseStoreStrategySelector + ._unspecifiedSelectorColor, + ), + }, + 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 + ? (v) { + final provider = context.read(); + + 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( + v, + ); + } + + provider.changedCurrentStores(); + } + : null, + ), + ); +} 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..3d63d230 --- /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,221 @@ +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.05); + + 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 localPosition = Offset(12 + 16, box.size.height / 2); + final globalPosition = box.localToGlobal(localPosition); + + _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: globalPosition), + ); + }); + }); + } + + _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: 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 new file mode 100644 index 00000000..b6064cbe --- /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,73 @@ +part of '../store_tile.dart'; + +class _Trailing extends StatelessWidget { + const _Trailing({ + required this.storeName, + required this.matchesUrl, + required this.useCompactLayout, + }); + + final String storeName; + final bool matchesUrl; + final bool useCompactLayout; + + @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: Colors.red.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(12), + ), + 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), + ), + ], + ), + ), + ), + ), + ), + ); + + 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, + ], + ), + ), + ), + ); + } +} 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..2c5dfdcc --- /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,299 @@ +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 'components/browse_store_strategy_selector/browse_store_strategy_selector.dart'; +import 'components/custom_single_slidable_action.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, + this.isFirstStore = false, + }); + + final String storeName; + final Future<({int hits, int length, int misses, double size})> stats; + final Future> metadata; + final Future tileImage; + final bool useCompactLayout; + final bool isFirstStore; + + @override + State createState() => _StoreTileState(); +} + +class _StoreTileState extends State + with SingleTickerProviderStateMixin { + static const _dismissThreshold = 0.25; + + late final _slidableController = SlidableController(this); + + bool _isEmptying = false; + + @override + void initState() { + super.initState(); + + if (widget.isFirstStore) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => Future.delayed(const Duration(seconds: 1), _hintTools), + ); + } + } + + @override + void dispose() { + _slidableController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => RepaintBoundary( + 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, + ), + ); + 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, + ), + ], + ), + 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, 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 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, + ); + }, + ), + ], + ), + 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, + ), + ), + ), + ), + ), + ), + ), + ), + ); + + 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, + ); + } + + Future _emptyOrDelete({ + required BoxConstraints outerConstraints, + }) async { + if ((await widget.stats).length == 0) { + if (!mounted) return false; + + if (context.read().storeName == widget.storeName) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cannot delete store whilst download is in progress'), + ), + ); + return false; + } + + 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))); + + 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 true; + } + + unawaited( + _slidableController.openTo( + -max( + _dismissThreshold, + 104 / outerConstraints.maxWidth, + ), + curve: Curves.easeOut, + ), + ); + + setState(() => _isEmptying = true); + + await FMTCStore(widget.storeName).manage.reset(); + + Future.delayed( + const Duration(milliseconds: 200), + () => setState(() => _isEmptying = false), + ); + + return false; + } +} 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 new file mode 100644 index 00000000..0b8a2018 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/components/tiles/unspecified_tile.dart @@ -0,0 +1,105 @@ +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 'store_tile/components/browse_store_strategy_selector/browse_store_strategy_selector.dart'; + +class UnspecifiedTile extends StatefulWidget { + const UnspecifiedTile({ + super.key, + required this.useCompactLayout, + }); + + final bool useCompactLayout; + + @override + State createState() => _UnspecifiedTileState(); +} + +class _UnspecifiedTileState extends State { + @override + Widget build(BuildContext context) { + final isAllUnselectedDisabled = context + .select( + (p) => p.currentStores['(unspecified)'], + ) + ?.toBrowseStoreStrategy() == + null || + context.select( + (p) => p.loadingStrategy, + ) == + BrowseLoadingStrategy.onlineFirst; + + return RepaintBoundary( + child: Material( + color: Colors.transparent, + child: ListTile( + title: const Text( + 'All unspecified', + 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), + ), + 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: [ + 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( + spacing: 4, + children: [ + const Icon(Icons.last_page), + 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), + BrowseStoreStrategySelector( + storeName: '(unspecified)', + enabled: true, + inheritable: false, + useCompactLayout: widget.useCompactLayout, + ), + if (widget.useCompactLayout) const SizedBox(width: 12), + ], + ), + ), + ), + ), + ); + } +} 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 new file mode 100644 index 00000000..c080ab28 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/components/stores_list/stores_list.dart @@ -0,0 +1,124 @@ +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/column_headers_and_inheritable_settings.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'; + +class StoresList extends StatefulWidget { + const StoresList({ + super.key, + required this.useCompactLayout, + }); + + final bool useCompactLayout; + + @override + State createState() => _StoresListState(); +} + +class _StoresListState extends State { + String? _firstStoreName; + + 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) + store: ( + stats: store.stats.all, + metadata: store.metadata.read, + tileImage: store.stats.tileImage( + size: 51.2, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + }; + }, + ); + + @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 NoStores(newStoreName: (store) => _firstStoreName = store); + } + + return SliverList.separated( + itemCount: stores.length + 4, + itemBuilder: (context, index) { + if (index == 0) { + return ColumnHeadersAndInheritableSettings( + useCompactLayout: widget.useCompactLayout, + ); + } + if (index - 1 == stores.length) { + return UnspecifiedTile( + useCompactLayout: widget.useCompactLayout, + ); + } + if (index - 2 == stores.length) { + return RootTile( + length: _rootLength, + size: _rootSize, + realSizeAdditional: _rootRealSizeAdditional, + ); + } + if (index - 3 == 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; + + return StoreTile( + 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 + ? const Divider() + : 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/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 new file mode 100644 index 00000000..2455e59e --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/home_view_bottom_sheet.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import '../../layouts/bottom_sheet/components/scrollable_provider.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}); + + @override + State createState() => _HomeViewBottomSheetState(); +} + +class _HomeViewBottomSheetState extends State { + @override + Widget build(BuildContext context) => CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + slivers: const [ + TabHeader(title: 'Stores & Config'), + SliverToBoxAdapter(child: SizedBox(height: 6)), + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter(child: MapConfigurator()), + ), + 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 new file mode 100644 index 00000000..b2534cc1 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/home/home_view_side.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../../layouts/side/components/panel.dart'; +import 'components/map_configurator/map_configurator.dart'; +import 'components/stores_list/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, bottom: 16), + sliver: StoresList( + useCompactLayout: widget.constraints.maxWidth / 3 < 500, + ), + ), + ], + ), + ), + ), + ], + ); +} 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..738ae13c --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/components/no_regions.dart @@ -0,0 +1,32 @@ +part of '../recoverable_regions_list.dart'; + +class _NoRegions extends StatelessWidget { + const _NoRegions(); + + @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/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 new file mode 100644 index 00000000..bf8df1d0 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/recovery/components/recoverable_regions_list/recoverable_regions_list.dart @@ -0,0 +1,181 @@ +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'; + +part 'components/no_regions.dart'; +part 'components/tile_resume_button.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 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), + ), + ), + ], + ); + }, + ), + ), + ); + }, + ), + ); + }, + ); + + 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..a404a623 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/recovery/recovery_view_side.dart @@ -0,0 +1,18 @@ +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: [RecoverableRegionsList()]), + ); +} 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..a4821bfa --- /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,67 @@ +part of '../shape_selector.dart'; + +class _AnimatedVisibilityIconButton extends StatelessWidget { + const _AnimatedVisibilityIconButton.outlined({ + required this.icon, + this.onPressed, + this.tooltip, + required this.isVisible, + // This is exactly what we want to do + // ignore: avoid_field_initializers_in_const_classes + }) : _mode = 0; + + const _AnimatedVisibilityIconButton.filledTonal({ + required this.icon, + this.onPressed, + this.tooltip, + required this.isVisible, + // This is exactly what we want to do + // ignore: avoid_field_initializers_in_const_classes + }) : _mode = 1; + + const _AnimatedVisibilityIconButton.filled({ + required this.icon, + this.onPressed, + this.tooltip, + required this.isVisible, + // This is exactly what we want to do + // 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..0b7fd79a --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/shape_selector.dart @@ -0,0 +1,268 @@ +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'; +import '../shared/to_config_method.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.isEmpty, + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + void _completeRegion() { + _addSubRegion(); + prepareDownloadConfigView(context); + } + + 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/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..3639a6ba --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shared/to_config_method.dart @@ -0,0 +1,38 @@ +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 prepareDownloadConfigView( + BuildContext context, { + bool shouldShowConfig = true, +}) { + 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) + + MediaQueryData.fromView(View.of(context)).padding + + const EdgeInsets.only(bottom: 18), + ), + ); + + if (shouldShowConfig) { + regionSelectionProvider.isDownloadSetupPanelVisible = true; + } +} 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..3ded4fda --- /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,41 @@ +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 constructed', + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + ), + ), + ); +} 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..a9bd51ee --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/sub_regions_list/sub_regions_list.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 '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) + + MediaQueryData.fromView(View.of(context)) + .padding + + const EdgeInsets.only(bottom: 18), + ), + ); + }, + 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/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..fbce3ecd --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_bottom_sheet.dart @@ -0,0 +1,69 @@ +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/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'; + +class RegionSelectionViewBottomSheet extends StatelessWidget { + const RegionSelectionViewBottomSheet({super.key}); + + @override + Widget build(BuildContext context) { + final hasConstructedRegions = context.select( + (p) => p.constructedRegions.isNotEmpty, + ); + + return CustomScrollView( + controller: + BottomSheetScrollableProvider.innerScrollControllerOf(context), + slivers: [ + const TabHeader(title: 'Download Selection'), + if (hasConstructedRegions) + const SubRegionsList() + else + const NoSubRegions(), + const SliverToBoxAdapter(child: SizedBox(height: 6)), + 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), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} 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..bd0de307 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/region_selection_view_side.dart @@ -0,0 +1,97 @@ +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/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'; + +class RegionSelectionViewSide extends StatelessWidget { + const RegionSelectionViewSide({super.key}); + + @override + 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: () => + 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 new file mode 100644 index 00000000..6da745d4 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/bottom_sheet.dart @@ -0,0 +1,119 @@ +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'; +import 'components/delayed_frame_attached_dependent_builder.dart'; +import 'components/scrollable_provider.dart'; + +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() => + _SecondaryViewBottomSheetState(); +} + +class _SecondaryViewBottomSheetState extends State { + @override + 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: widget.controller, + builder: (context, child) { + final screenTopPadding = + MediaQueryData.fromView(View.of(context)).padding.top; + + final double paddingPusherHeight = + widget.controller.isAttached + ? (screenTopPadding - + constraints.maxHeight + + widget.controller.pixels) + .clamp(0, screenTopPadding) + : 0; + + 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: 150), + 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.isFocused, + ) + ? 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/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 new file mode 100644 index 00000000..1d2e61b5 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/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/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 new file mode 100644 index 00000000..1c1ee0e5 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/components/scrollable_provider.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; + +class BottomSheetScrollableProvider extends InheritedWidget { + const BottomSheetScrollableProvider({ + super.key, + required super.child, + required this.innerScrollController, + required this.outerScrollController, + }); + + final ScrollController innerScrollController; + final DraggableScrollableController outerScrollController; + + Widget build(BuildContext context) => child; + + static ScrollController innerScrollControllerOf(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType()! + .innerScrollController; + + static DraggableScrollableController? maybeOuterScrollControllerOf( + BuildContext context, + ) => + context + .dependOnInheritedWidgetOfExactType() + ?.outerScrollController; + + static DraggableScrollableController outerScrollControllerOf( + BuildContext context, + ) => + maybeOuterScrollControllerOf(context)!; + + @override + bool updateShouldNotify(covariant BottomSheetScrollableProvider oldWidget) => + oldWidget.innerScrollController != innerScrollController || + oldWidget.outerScrollController != outerScrollController; +} 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 new file mode 100644 index 00000000..950a6fd7 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/layouts/bottom_sheet/utils/tab_header.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; + +import '../components/delayed_frame_attached_dependent_builder.dart'; +import '../components/scrollable_provider.dart'; + +class TabHeader extends StatelessWidget { + const TabHeader({ + super.key, + required this.title, + }); + + 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); + + return SliverPersistentHeader( + pinned: true, + delegate: _PersistentHeader( + child: DelayedControllerAttachmentBuilder( + listenable: outerScrollController, + builder: (context, _) { + if (!outerScrollController.isAttached || + innerScrollController.positions.length != 1) { + 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, + ), + ), + ), + ], + ); + } + + return AnimatedBuilder( + animation: innerScrollController, + builder: (context, child) => AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + color: innerScrollController.offset != 0 + ? Theme.of(context).colorScheme.surfaceContainer + : Theme.of(context).colorScheme.surface, + child: child, + ), + 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(8)), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ); + }, + child: ClipRRect( + child: IconButton( + onPressed: () { + outerScrollController.animateTo( + 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + innerScrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }, + icon: const Icon(Icons.keyboard_arrow_down), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} + +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; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) => + Align(child: child); + + @override + double get maxExtent => 84; + + @override + double get minExtent => 84; + + @override + bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; +} 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..9717f977 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/layouts/side/components/panel.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +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( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + ), + padding: autoPadding ? const EdgeInsets.all(16) : null, + 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..7f385044 --- /dev/null +++ b/example/lib/src/screens/main/secondary_view/layouts/side/side.dart @@ -0,0 +1,106 @@ +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/recovery/recovery_view_side.dart'; +import '../../contents/region_selection/region_selection_view_side.dart'; + +class SecondaryViewSide extends StatelessWidget { + const SecondaryViewSide({ + 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) { + 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: 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)), + }, + ), + ); +} 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..5c75b519 --- /dev/null +++ b/example/lib/src/screens/store_editor/store_editor.dart @@ -0,0 +1,202 @@ +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'; + +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? existingMaxLength; + late final Future> existingStores; + + String? newName; + String? newUrlTemplate; + int? newMaxLength; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + existingStoreName = ModalRoute.of(context)!.settings.arguments as String?; + 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)); + } + + @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)' || + input == '(unspecified)' + ? 'Name reserved (in example app)' + : null, + onSaved: (input) => newName = input, + maxLength: 64, + autovalidateMode: AutovalidateMode.onUserInteraction, + initialValue: existingStoreName, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + autofocus: true, + ), + ), + const SizedBox(height: 6), + FutureBuilder( + future: existingMetadata, + builder: (context, snapshot) { + if (snapshot.data == null && + existingStoreName != null) { + return const CircularProgressIndicator.adaptive(); + } + + return UrlSelector( + onSelected: (input) => newUrlTemplate = input, + initialValue: snapshot + .data?[StoreMetadataKeys.urlTemplate.key] ?? + context.select( + (provider) => provider.urlTemplate, + ), + helperText: + 'In the example app, stores only contain tiles ' + 'from one source', + ); + }, + ), + 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(existingStoreName == null ? newName : null); + }, + child: existingStoreName == null + ? const Icon(Icons.save) + : const Icon(Icons.save_as), + ), + ); +} 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..94b67277 --- /dev/null +++ b/example/lib/src/shared/components/url_selector.dart @@ -0,0 +1,286 @@ +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'; + +import '../misc/shared_preferences.dart'; +import '../misc/store_metadata_keys.dart'; + +class UrlSelector extends StatefulWidget { + const UrlSelector({ + super.key, + required 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(); +} + +class _UrlSelectorState extends State { + 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, + ); + + final _selectableEntriesManualRefreshStream = StreamController(); + + late final _templatesToStoresStream = + (StreamGroup>>() + ..add( + _transformToTemplatesToStoresOnTrigger( + FMTCRoot.stats.watchStores(triggerImmediately: true), + ), + ) + ..add( + _transformToTemplatesToStoresOnTrigger( + _selectableEntriesManualRefreshStream.stream, + ), + )) + .stream; + + 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(); + } + + @override + 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, + expandedInsets: EdgeInsets.zero, // full width + 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, + ), + ), + 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( + SharedPrefsKeys.customNonStoreUrls.name, + (sharedPrefs.getStringList(SharedPrefsKeys.customNonStoreUrls.name) ?? + []) + ..add(_urlTextController.text), + ); + + _selectableEntriesManualRefreshStream.add(null); + } + + widget.onSelected!(v ?? _urlTextController.text); + _dropdownMenuFocusNode?.unfocus(); + } + + 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( + SharedPrefsKeys.customNonStoreUrls.name, + (sharedPrefs.getStringList( + SharedPrefsKeys.customNonStoreUrls.name, + ) ?? + []) + ..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, + ), + ); + + 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() { + WidgetsBinding.instance.addPostFrameCallback((_) { + _enableAddUrlButton.value = + !_enableButtonEvaluatorMap.containsKey(_urlTextController.text); + }); + } +} + +// 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/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 87% rename from example/lib/shared/misc/exts/size_formatter.dart rename to example/lib/src/shared/misc/exts/size_formatter.dart index 97004f09..d3390633 100644 --- a/example/lib/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/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..70a08d14 --- /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 [BrowseStoreStrategy]. +enum InternalBrowseStoreStrategy { + /// 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; + + BrowseStoreStrategy? toBrowseStoreStrategy([ + BrowseStoreStrategy? inheritableBehaviour, + ]) => + switch (this) { + disable => null, + inherit => inheritableBehaviour, + read => BrowseStoreStrategy.read, + readUpdate => BrowseStoreStrategy.readUpdate, + readUpdateCreate => BrowseStoreStrategy.readUpdateCreate, + }; + + static InternalBrowseStoreStrategy fromBrowseStoreStrategy( + BrowseStoreStrategy? behaviour, + ) => + switch (behaviour) { + null => InternalBrowseStoreStrategy.disable, + BrowseStoreStrategy.read => InternalBrowseStoreStrategy.read, + BrowseStoreStrategy.readUpdate => + InternalBrowseStoreStrategy.readUpdate, + BrowseStoreStrategy.readUpdateCreate => + InternalBrowseStoreStrategy.readUpdateCreate, + }; + + static const priority = [ + null, + BrowseStoreStrategy.read, + BrowseStoreStrategy.readUpdate, + BrowseStoreStrategy.readUpdateCreate, + ]; +} 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..85048d7b --- /dev/null +++ b/example/lib/src/shared/misc/shared_preferences.dart @@ -0,0 +1,15 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +late SharedPreferences sharedPrefs; + +enum SharedPrefsKeys { + mapLocationLat, + mapLocationLng, + mapLocationZoom, + customNonStoreUrls, + urlTemplate, + inheritableBrowseStoreStrategy, + browseLoadingStrategy, + displayDebugOverlay, + fakeNetworkDisconnect, +} 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/download_configuration_provider.dart b/example/lib/src/shared/state/download_configuration_provider.dart new file mode 100644 index 00000000..f5f56db5 --- /dev/null +++ b/example/lib/src/shared/state/download_configuration_provider.dart @@ -0,0 +1,108 @@ +import 'package:flutter/foundation.dart'; + +class DownloadConfigurationProvider extends ChangeNotifier { + static const defaultValues = ( + minZoom: 0, + maxZoom: 14, + startTile: 1, + endTile: null, + parallelThreads: 3, + rateLimit: 200, + maxBufferLength: 0, + skipExistingTiles: false, + skipSeaTiles: true, + retryFailedRequestTiles: true, + fromRecovery: null, + ); + + 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 = newNum; + notifyListeners(); + } + + 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 = defaultValues.skipExistingTiles; + bool get skipExistingTiles => _skipExistingTiles; + set skipExistingTiles(bool newState) { + _skipExistingTiles = newState; + notifyListeners(); + } + + bool _skipSeaTiles = defaultValues.skipSeaTiles; + bool get skipSeaTiles => _skipSeaTiles; + set skipSeaTiles(bool newState) { + _skipSeaTiles = newState; + 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) { + _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 new file mode 100644 index 00000000..e19c2307 --- /dev/null +++ b/example/lib/src/shared/state/download_provider.dart @@ -0,0 +1,112 @@ +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 get isPaused => FMTCStore(storeName!).download.isPaused(); + + bool _isComplete = false; + bool get isComplete => _isComplete; + + DownloadableRegion? _downloadableRegion; + DownloadableRegion get downloadableRegion => + _downloadableRegion ?? (throw _notReadyError); + + DownloadProgress? _latestDownloadProgress; + DownloadProgress get latestDownloadProgress => + _latestDownloadProgress ?? (throw _notReadyError); + + TileEvent? _latestTileEvent; + TileEvent? get latestTileEvent => _latestTileEvent; + + Stream? _rawTileEventsStream; + Stream get rawTileEventStream => + _rawTileEventsStream ?? (throw _notReadyError); + + String? _storeName; + String? get storeName => _storeName; + + Future assignDownload({ + required String storeName, + required DownloadableRegion downloadableRegion, + required ({ + Stream downloadProgress, + Stream tileEvents + }) downloadStreams, + }) { + final focused = Completer(); + + _storeName = storeName; + _downloadableRegion = downloadableRegion; + + _rawTileEventsStream = downloadStreams.tileEvents.asBroadcastStream(); + + bool isFirstEvent = true; + downloadStreams.downloadProgress.listen( + (evt) { + // Focus on initial event + if (isFirstEvent) { + _isFocused = true; + focused.complete(); + isFirstEvent = false; + } + + // Update stored value + _latestDownloadProgress = evt; + notifyListeners(); + }, + onDone: () { + _isComplete = true; + notifyListeners(); + }, + ); + + _rawTileEventsStream!.listen((evt) { + // Update stored value + _latestTileEvent = evt; + notifyListeners(); + }); + + return focused.future; + } + + Future pause() async { + assert(_storeName != null, 'Download not in progress'); + await FMTCStore(_storeName!).download.pause(); + notifyListeners(); + } + + void resume() { + assert(_storeName != null, 'Download not in progress'); + FMTCStore(_storeName!).download.resume(); + notifyListeners(); + } + + Future cancel() { + assert(_storeName != null, 'Download not in progress'); + return FMTCStore(_storeName!).download.cancel(); + } + + void reset() { + _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/example/lib/src/shared/state/general_provider.dart b/example/lib/src/shared/state/general_provider.dart new file mode 100644 index 00000000..7b57cb66 --- /dev/null +++ b/example/lib/src/shared/state/general_provider.dart @@ -0,0 +1,131 @@ +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); + 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(); + + final Set explicitlyExcludedStores = {}; + void changedExplicitlyExcludedStores() => notifyListeners(); + + 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 = () { + 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 = + 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/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/region_selection_provider.dart b/example/lib/src/shared/state/region_selection_provider.dart new file mode 100644 index 00000000..dfcb13d6 --- /dev/null +++ b/example/lib/src/shared/state/region_selection_provider.dart @@ -0,0 +1,131 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:latlong2/latlong.dart'; + +enum RegionSelectionMethod { + useMapCenter, + usePointer, +} + +enum RegionType { + rectangle, + circle, + line, + customPolygon, +} + +class RegionSelectionProvider extends ChangeNotifier { + RegionSelectionMethod _currentRegionSelectionMethod = + Platform.isAndroid || Platform.isIOS + ? RegionSelectionMethod.useMapCenter + : RegionSelectionMethod.usePointer; + RegionSelectionMethod get regionSelectionMethod => + _currentRegionSelectionMethod; + set regionSelectionMethod(RegionSelectionMethod newMethod) { + _currentRegionSelectionMethod = newMethod; + notifyListeners(); + } + + LatLng? _currentNewPointPos; + LatLng? get currentNewPointPos => _currentNewPointPos; + set currentNewPointPos(LatLng? newPos) { + _currentNewPointPos = newPos; + notifyListeners(); + } + + RegionType _currentRegionType = RegionType.rectangle; + RegionType get currentRegionType => _currentRegionType; + set currentRegionType(RegionType newType) { + _currentRegionType = newType; + notifyListeners(); + } + + 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 _currentConstructingCoordinates = []; + List get currentConstructingCoordinates => + List.unmodifiable(_currentConstructingCoordinates); + List addCoordinate(LatLng coord) { + _currentConstructingCoordinates.add(coord); + notifyListeners(); + return _currentConstructingCoordinates; + } + + List addCoordinates(Iterable coords) { + _currentConstructingCoordinates.addAll(coords); + notifyListeners(); + return _currentConstructingCoordinates; + } + + void clearCoordinates() { + _currentConstructingCoordinates.clear(); + notifyListeners(); + } + + void removeLastCoordinate() { + if (_currentConstructingCoordinates.isNotEmpty) { + _currentConstructingCoordinates.removeLast(); + } + notifyListeners(); + } + + double _lineRadius = 100; + double get lineRadius => _lineRadius; + set lineRadius(double newNum) { + _lineRadius = newNum; + notifyListeners(); + } + + bool _customPolygonSnap = false; + bool get customPolygonSnap => _customPolygonSnap; + set customPolygonSnap(bool newState) { + _customPolygonSnap = newState; + notifyListeners(); + } + + bool _isDownloadSetupPanelVisible = false; + bool get isDownloadSetupPanelVisible => _isDownloadSetupPanelVisible; + set isDownloadSetupPanelVisible(bool newState) { + _isDownloadSetupPanelVisible = 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 87622d2d..b48b6e7c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,38 +1,41 @@ -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: 9.1.4 +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.12.0 auto_size_text: ^3.0.0 badges: ^3.1.2 collection: ^1.18.0 - dart_earcut: ^1.1.0 - file_picker: ^8.0.3 + file_picker: 8.1.4 # Compatible with 3.27! flutter: sdk: flutter - flutter_map: ^7.0.2 - flutter_map_animations: ^0.7.0 + flutter_map: + flutter_map_animations: ^0.8.0 flutter_map_tile_caching: + flutter_slidable: ^3.1.2 google_fonts: ^6.2.1 - gpx: ^2.2.2 - http: ^1.2.1 + gpx: ^2.3.0 + 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.3 + path: ^1.9.1 + path_provider: ^2.1.5 provider: ^6.1.2 + share_plus: ^10.1.3 + shared_preferences: ^2.3.3 stream_transform: ^2.1.0 - validators: ^3.0.0 dependency_overrides: + flutter_map: + git: + url: https://github.com/fleaflet/flutter_map.git + ref: d816b4d54f9245e260b125ea1adbf300b5c39843 flutter_map_tile_caching: path: ../ 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/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/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/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 ff029497..dfb4b25c 100644 --- a/lib/custom_backend_api.dart +++ b/lib/custom_backend_api.dart @@ -8,11 +8,11 @@ /// 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. /// /// 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 a731dddb..d90df1d6 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -8,59 +8,64 @@ /// /// * [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'; 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'; 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/misc/int_extremes.dart'; -import 'src/misc/obscure_query_params.dart'; -import 'src/providers/browsing_errors.dart'; -import 'src/providers/image_provider.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/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/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/tile_provider.dart'; -part 'src/providers/tile_provider_settings.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/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_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'; +part 'src/providers/tile_provider/typedefs.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/shapes/multi.dart'; part 'src/regions/recovered_region.dart'; -part 'src/regions/rectangle.dart'; -part 'src/root/root.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'; 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/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/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/backend.dart b/lib/src/backend/impls/objectbox/backend/backend.dart index 23fed73f..5bb750cc 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'; @@ -15,9 +16,11 @@ 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'; +import '../models/src/recovery_region.dart'; import '../models/src/root.dart'; import '../models/src/store.dart'; import '../models/src/tile.dart'; @@ -25,29 +28,24 @@ 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'; -part 'errors.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]. Defaults to 10 GB. + /// 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 @@ -59,7 +57,6 @@ final class FMTCObjectBoxBackend implements FMTCBackend { /// thread. /// /// Avoid using [useInMemoryDatabase] outside of testing purposes. - /// {@endtemplate} @override Future initialise({ String? rootDirectory, @@ -78,14 +75,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/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.dart b/lib/src/backend/impls/objectbox/backend/internal.dart index 823ef7ec..b6e63090 100644 --- a/lib/src/backend/impls/objectbox/backend/internal.dart +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -27,15 +27,15 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { SendPort? _sendPort; final _workerResOneShot = ?>>{}; final _workerResStreamed = ?>>{}; - int _workerId = 0; + int _workerId = smallestInt; late Completer _workerComplete; late StreamSubscription _workerHandler; // `removeOldestTilesAboveLimit` tracking & debouncing Timer? _rotalDebouncer; - String? _rotalStore; - Completer? _rotalResultCompleter; + int? _rotalStoresHash; + Completer>? _rotalResultCompleter; // Define communicators @@ -88,27 +88,22 @@ 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 + 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 +202,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({ @@ -268,7 +269,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { _workerResStreamed.clear(); _rotalDebouncer?.cancel(); _rotalDebouncer = null; - _rotalStore = null; + _rotalStoresHash = null; _rotalResultCompleter?.completeError(RootUnavailable()); _rotalResultCompleter = null; @@ -294,6 +295,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 +326,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,24 +374,35 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ))!['stats']; @override - Future tileExistsInStore({ - required String storeName, + Future tileExists({ required String url, + required ({bool includeOrExclude, List storeNames}) storeNames, }) async => (await _sendCmdOneShot( - type: _CmdType.tileExistsInStore, - args: {'storeName': storeName, 'url': url}, + type: _CmdType.tileExists, + args: {'url': url, 'storeNames': storeNames}, ))!['exists']; @override - Future readTile({ + Future< + ({ + BackendTile? tile, + List intersectedStoreNames, + List allStoreNames, + })> readTile({ required String url, - String? storeName, - }) async => - (await _sendCmdOneShot( - type: _CmdType.readTile, - args: {'url': url, 'storeName': storeName}, - ))!['tile']; + required ({bool includeOrExclude, List storeNames}) storeNames, + }) async { + final res = (await _sendCmdOneShot( + type: _CmdType.readTile, + args: {'url': url, 'storeNames': storeNames}, + ))!; + return ( + tile: res['tile'] as BackendTile?, + intersectedStoreNames: res['intersectedStoreNames'] as List, + allStoreNames: res['allStoreNames'] as List, + ); + } @override Future readLatestTile({ @@ -382,15 +414,21 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ))!['tile']; @override - Future writeTile({ - required String storeName, + Future> writeTile({ required String url, required Uint8List bytes, - }) => - _sendCmdOneShot( + required List storeNames, + required List? writeAllNotIn, + }) async => + (await _sendCmdOneShot( type: _CmdType.writeTile, - args: {'storeName': storeName, 'url': url, 'bytes': bytes}, - ); + args: { + 'storeNames': storeNames, + 'writeAllNotIn': writeAllNotIn, + 'url': url, + 'bytes': bytes, + }, + ))!['result']; @override Future deleteTile({ @@ -403,36 +441,43 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ))!['wasOrphan']; @override - Future registerHitOrMiss({ - required String storeName, - required bool hit, + Future incrementStoreHits({ + required List storeNames, }) => _sendCmdOneShot( - type: _CmdType.registerHitOrMiss, - args: {'storeName': storeName, 'hit': hit}, + type: _CmdType.incrementStoreHits, + args: {'storeNames': storeNames}, ); @override - Future removeOldestTilesAboveLimit({ - required String storeName, - required int tilesLimit, + Future incrementStoreMisses({ + required ({bool includeOrExclude, List storeNames}) storeNames, + }) => + _sendCmdOneShot( + type: _CmdType.incrementStoreMisses, + args: {'storeNames': storeNames}, + ); + + @override + 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}, - ).then((v) => v!['numOrphans']), + args: {'storeNames': storeNames}, + ).then((v) => v!['orphansCounts']), ); // 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(); @@ -460,7 +505,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { (await _sendCmdOneShot( type: _CmdType.removeTilesOlderThan, args: {'storeName': storeName, 'expiry': expiry}, - ))!['numOrphans']; + ))!['orphansCount']; @override Future> readMetadata({ @@ -478,8 +523,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 @@ -554,7 +602,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { ); @override - Future exportStores({ + Future exportStores({ required List storeNames, required String path, }) async { @@ -567,10 +615,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/shared.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart index 926d7484..eb2fe618 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart @@ -3,36 +3,81 @@ part of '../backend.dart'; -void _sharedWriteSingleTile({ +List _resolveReadableStoresFormat( + ({bool includeOrExclude, List storeNames}) readableStores, { required Store root, - required String storeName, +}) { + 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, required String url, required Uint8List bytes, + List? writeAllNotIn, }) { final tiles = root.box(); - final stores = root.box(); + 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, + ...availableStoreNames.whereNot( + (e) => writeAllNotIn.contains(e) || storeNames.contains(e), + ), + ]; + + if (compiledStoreNames.isEmpty) return const {}; + final tilesQuery = tiles.query(ObjectBoxTile_.url.equals(url)).build(); final storeQuery = - stores.query(ObjectBoxStore_.name.equals(storeName)).build(); + storesBox.query(ObjectBoxStore_.name.oneOf(compiledStoreNames)).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; + + final result = {for (final storeName in compiledStoreNames) storeName: 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(compiledStoreNames.where((s) => s == relatedStore.name)); storesToUpdate[relatedStore.name] = (storesToUpdate[relatedStore.name] ?? relatedStore) @@ -45,6 +90,20 @@ void _sharedWriteSingleTile({ ..size += -existingTile.bytes.lengthInBytes + bytes.lengthInBytes, mode: PutMode.update, ); + + storesToUpdate.addEntries( + stores.whereNot((s) => didContainAlready.contains(s.name)).map( + (s) { + result[s.name] = true; + return MapEntry( + s.name, + s + ..length += 1 + ..size += bytes.lengthInBytes, + ); + }, + ), + ); } else { rootBox.put( rootBox.get(1)! @@ -52,12 +111,20 @@ 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) { + result[s.name] = true; + return MapEntry( + s.name, + s + ..length += 1 + ..size += bytes.lengthInBytes, + ); + }, + ), + ); } tiles.put( @@ -65,12 +132,14 @@ 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); }, ); tilesQuery.close(); storeQuery.close(); + + return result; } 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..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 @@ -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, @@ -10,22 +12,24 @@ enum _CmdType { rootSize, rootLength, listStores, + storeGetMaxLength, + storeSetMaxLength, storeExists, createStore, resetStore, renameStore, deleteStore, getStoreStats, - tileExistsInStore, + tileExists, readTile, readLatestTile, writeTile, deleteTile, - registerHitOrMiss, + incrementStoreHits, + incrementStoreMisses, removeOldestTilesAboveLimit, removeTilesOlderThan, readMetadata, - setMetadata, setBulkMetadata, removeMetadata, resetMetadata, 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/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index 51f542af..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 @@ -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(); @@ -107,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 @@ -139,6 +142,8 @@ Future _worker( rootDeltaSize -= tile.bytes.lengthInBytes; tilesToRemove.add(tile.id); } + + if (tilesChunk.length < tilesChunkSize) break; } if (!hadTilesToUpdate && tilesToRemove.isEmpty) return 0; @@ -280,13 +285,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, @@ -378,15 +424,20 @@ 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 ({ + bool includeOrExclude, + List storeNames, + }); final query = (root.box().query(ObjectBoxTile_.url.equals(url)) ..linkMany( ObjectBoxTile_.stores, - ObjectBoxStore_.name.equals(storeName), + ObjectBoxStore_.name.oneOf( + _resolveReadableStoresFormat(storeNames, root: root), + ), )) .build(); @@ -395,23 +446,50 @@ 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 ({ + bool includeOrExclude, + List storeNames, + }); - final stores = root.box(); + final resolvedStores = + _resolveReadableStoresFormat(storeNames, root: root); - final queryPart = stores.query(ObjectBoxTile_.url.equals(url)); - final query = storeName == null - ? queryPart.build() - : (queryPart + final query = + (root.box().query(ObjectBoxTile_.url.equals(url)) ..linkMany( ObjectBoxTile_.stores, - ObjectBoxStore_.name.equals(storeName), + ObjectBoxStore_.name.oneOf(resolvedStores), )) .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, + 'allStoreNames': const [], + 'intersectedStoreNames': const [], + }, + ); + } else { + 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': intersectedStoreNames, + }, + ); + } case _CmdType.readLatestTile: final storeName = cmd.args['storeName']! as String; @@ -429,18 +507,20 @@ Future _worker( query.close(); case _CmdType.writeTile: - final storeName = cmd.args['storeName']! as String; + 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, - storeName: storeName, + storeNames: storeNames, + writeAllNotIn: writeAllNotIn, url: url, bytes: bytes, ); - sendRes(id: cmd.id); + sendRes(id: cmd.id, data: {'result': result}); case _CmdType.deleteTile: final storeName = cmd.args['storeName']! as String; final url = cmd.args['url']! as String; @@ -454,44 +534,73 @@ Future _worker( .query(ObjectBoxTile_.url.equals(url)) .build(); - final orphans = + final orphansCount = deleteTiles(storesQuery: storesQuery, tilesQuery: tilesQuery); sendRes( id: cmd.id, - data: {'wasOrphan': orphans == 1}, + data: {'wasOrphan': orphansCount == 1}, ); storesQuery.close(); tilesQuery.close(); - case _CmdType.registerHitOrMiss: - final storeName = cmd.args['storeName']! as String; - final hit = cmd.args['hit']! as bool; + case _CmdType.incrementStoreHits: + final storeNames = cmd.args['storeNames'] as List; - final stores = root.box(); + final storesBox = root.box(); final query = - stores.query(ObjectBoxStore_.name.equals(storeName)).build(); + storesBox.query(ObjectBoxStore_.name.oneOf(storeNames)).build(); root.runInTransaction( TxMode.write, () { - final store = query.findUnique() ?? - (throw StoreNotExists(storeName: storeName)); - query.close(); + final stores = query.find(); + + if (stores.length != storeNames.length) { + return StoreNotExists( + storeName: storeNames + .toSet() + .difference(stores.map((s) => s.name).toSet()) + .join('; '), + ); + } - stores.put( - store - ..hits += hit ? 1 : 0 - ..misses += hit ? 0 : 1, - ); + 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..misses += 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() @@ -499,40 +608,45 @@ 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 orphansCounts = storeNames.map( + (storeName) { + tilesQuery.param(ObjectBoxStore_.name).value = storeName; + storeQuery.param(ObjectBoxStore_.name).value = storeName; - final numToRemove = store.length - tilesLimit; + final store = storeQuery.findUnique(); + if (store == null) return 0; - if (numToRemove <= 0) { - sendRes(id: cmd.id, data: {'numOrphans': 0}); + final numToRemove = store.length - store.maxLength!; + if (numToRemove <= 0) return 0; - storeQuery.close(); - tilesQuery.close(); - } else { - final orphans = deleteTiles( - storesQuery: storeQuery, - tilesQuery: tilesQuery, - limitTiles: numToRemove, - ); + return deleteTiles( + storesQuery: storeQuery, + tilesQuery: tilesQuery, + limitTiles: numToRemove, + ); + }, + ); - sendRes( - id: cmd.id, - data: {'numOrphans': orphans}, - ); + sendRes( + id: cmd.id, + data: {'orphansCounts': Map.fromIterables(storeNames, orphansCounts)}, + ); - 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; @@ -550,12 +664,12 @@ Future _worker( )) .build(); - final orphans = + final orphansCount = deleteTiles(storesQuery: storesQuery, tilesQuery: tilesQuery); sendRes( id: cmd.id, - data: {'numOrphans': orphans}, + data: {'orphansCount': orphansCount}, ); storesQuery.close(); @@ -580,35 +694,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; @@ -722,14 +807,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; @@ -756,7 +859,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.', ); } @@ -768,20 +872,24 @@ Future _worker( final storeNames = cmd.args['storeNames']! as List; final outputPath = cmd.args['outputPath']! as String; - final outputDir = path.dirname(outputPath); - - if (path.equals(outputDir, input.rootDirectory)) { - throw ExportInRootDirectoryForbidden(); - } + final workingDir = + Directory(path.join(input.rootDirectory, 'export_working_dir')); - 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() @@ -811,6 +919,7 @@ Future _worker( storesObjectsForRelations[exportingStore.name] = ObjectBoxStore( name: exportingStore.name, + maxLength: exportingStore.maxLength, length: exportingStore.length, size: exportingStore.size, hits: exportingStore.hits, @@ -825,7 +934,7 @@ Future _worker( .length .then( (numExportedStores) { - if (numExportedStores == 0) throw StateError('Unpossible.'); + if (numExportedStores == 0) throw StateError('Unpossible'); final exportingTiles = root.runInTransaction( TxMode.read, @@ -847,7 +956,7 @@ Future _worker( .map( (s) => storesObjectsForRelations[s.name], ) - .whereNotNull(), + .nonNulls, ), mode: PutMode.insert, ); @@ -868,11 +977,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)) @@ -884,33 +992,64 @@ Future _worker( ram.closeSync(); } - sendRes(id: cmd.id); + try { + dbFile.renameSync(outputPath); + } on FileSystemException { + dbFile.copySync(outputPath); + } finally { + workingDir.deleteSync(recursive: true); + } + + sendRes( + id: cmd.id, + data: {'numExportedTiles': numExportedTiles}, + ); }, - ); + ).catchError((error, stackTrace) { + 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); + }); }, - ); + ).catchError((error, stackTrace) { + 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); + }); 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, ); @@ -932,14 +1071,10 @@ 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 = {}; - // ignore: unnecessary_parenthesis (switch (strategy) { ImportConflictStrategy.skip => importingStoresQuery .stream() @@ -960,6 +1095,7 @@ Future _worker( root.box().put( ObjectBoxStore( name: name, + maxLength: importingStore.maxLength, length: importingStore.length, size: importingStore.size, hits: 0, @@ -985,6 +1121,7 @@ Future _worker( root.box().put( ObjectBoxStore( name: name, + maxLength: importingStore.maxLength, length: importingStore.length, size: importingStore.size, hits: 0, @@ -1004,6 +1141,7 @@ Future _worker( root.box().put( ObjectBoxStore( name: newName, + maxLength: importingStore.maxLength, length: importingStore.length, size: importingStore.size, hits: 0, @@ -1029,6 +1167,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, @@ -1042,6 +1181,7 @@ Future _worker( if (strategy == ImportConflictStrategy.merge) { root.box().put( existingStore + ..maxLength = importingStore.maxLength ..metadataJson = jsonEncode( (jsonDecode(existingStore.metadataJson) as Map) @@ -1146,6 +1286,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, @@ -1343,6 +1484,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/impls/objectbox/backend/internal_workers/thread_safe.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart index 25f20b0f..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 @@ -62,7 +62,7 @@ class _ObjectBoxBackendThreadSafeImpl implements FMTCBackendInternalThreadSafe { }) => _sharedWriteSingleTile( root: expectInitialisedRoot, - storeName: storeName, + storeNames: [storeName], url: url, bytes: bytes, ); @@ -151,17 +151,39 @@ class _ObjectBoxBackendThreadSafeImpl implements FMTCBackendInternalThreadSafe { required int id, required String storeName, required DownloadableRegion region, - required int endTile, - }) => - expectInitialisedRoot.box().put( + required int tilesCount, + }) { + 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, + endTile: region.end ?? (region.start - 1 + tilesCount), + 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 d8e27ba4..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,76 +52,19 @@ "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": [] }, { "id": "2:632249766926720928", - "lastPropertyId": "7:7028109958959828879", + "lastPropertyId": "8:3489822621946254204", "name": "ObjectBoxStore", "properties": [ { @@ -161,6 +104,11 @@ "id": "7:7028109958959828879", "name": "metadataJson", "type": 9 + }, + { + "id": "8:3489822621946254204", + "name": "maxLength", + "type": 6 } ], "relations": [] @@ -227,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 b6a4b25c..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,77 +71,19 @@ 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: []), 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( @@ -178,6 +121,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: [], @@ -244,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: []) ]; @@ -282,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, @@ -297,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) { @@ -305,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); @@ -326,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; }, @@ -351,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 = @@ -361,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( @@ -428,7 +411,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); @@ -436,6 +419,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; }, @@ -444,6 +428,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 = @@ -457,6 +443,7 @@ obx_int.ModelDefinition getObjectBoxModel() { .vTableGet(buffer, rootOffset, 16, ''); final object = ObjectBoxStore( name: nameParam, + maxLength: maxLengthParam, length: lengthParam, size: sizeParam, hits: hitsParam, @@ -533,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; }) }; @@ -573,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. @@ -657,6 +693,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. @@ -696,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..73443894 --- /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 = 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 + ? 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/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/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/backend/interfaces/backend/internal.dart b/lib/src/backend/interfaces/backend/internal.dart index b1e1eaf7..904400e6 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. @@ -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} @@ -114,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 @@ -125,19 +157,37 @@ 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 + /// + /// {@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, + required ({List storeNames, bool includeOrExclude}) storeNames, }); - /// Retrieve a raw tile by the specified URL + /// Retrieve a raw `tile` by URL from any of the specified stores + /// + /// {@macro fmtc.backend._readableStoresFormat} /// - /// If [storeName] is specified, the tile will be limited to the specified - /// store - if it exists in another store, it will not be returned. - Future readTile({ + /// 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`. + Future< + ({ + BackendTile? tile, + List intersectedStoreNames, + List allStoreNames, + })> readTile({ required String url, - String? storeName, + required ({List storeNames, bool includeOrExclude}) storeNames, }); /// {@template fmtc.backend.readLatestTile} @@ -150,10 +200,14 @@ abstract interface class FMTCBackendInternal /// Create or update a tile (given a [url] and its [bytes]) in the specified /// store - Future writeTile({ - required String storeName, + /// + /// 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, + required List? writeAllNotIn, }); /// Remove the tile from the specified store, deleting it if was orphaned @@ -173,27 +227,35 @@ abstract interface class FMTCBackendInternal required String url, }); - /// Register a cache hit or miss on the specified store - Future registerHitOrMiss({ - required String storeName, - required bool hit, + /// Add a cache hit to all specified stores + Future incrementStoreHits({ + required List storeNames, }); - /// Remove tiles in excess of the specified limit from the specified store, - /// oldest first + /// 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, + /// 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. + /// + /// 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. /// - /// Throws [RootUnavailable] if the root is uninitialised whilst the + /// 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} @@ -228,8 +290,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, @@ -322,7 +385,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/backend/interfaces/backend/internal_thread_safe.dart b/lib/src/backend/interfaces/backend/internal_thread_safe.dart index e38d173a..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'; @@ -75,7 +78,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/backend/interfaces/models.dart b/lib/src/backend/interfaces/models.dart index cd764328..b0ab75bf 100644 --- a/lib/src/backend/interfaces/models.dart +++ b/lib/src/backend/interfaces/models.dart @@ -2,43 +2,24 @@ // A full license can be found at .\LICENSE 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) /// /// 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 [FMTCTileProvider.urlTransformer]. String get url; /// 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 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/bulk_download/download_progress.dart b/lib/src/bulk_download/download_progress.dart deleted file mode 100644 index 0321af29..00000000 --- a/lib/src/bulk_download/download_progress.dart +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of '../../flutter_map_tile_caching.dart'; - -/// Statistics and information about the current progress of the download -/// -/// See the documentation on each individual property for more information. -@immutable -class DownloadProgress { - const DownloadProgress.__({ - required TileEvent? 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, - required this.elapsedDuration, - required this.tilesPerSecond, - required this.isTPSArtificiallyCapped, - required this.isComplete, - }) : _latestTileEvent = latestTileEvent; - - 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, - ); - - /// The result of the latest attempted tile - /// - /// {@macro fmtc.tileevent.extraConsiderations} - TileEvent get latestTileEvent => _latestTileEvent!; - final TileEvent? _latestTileEvent; - - /// The number of new tiles successfully downloaded and in the tile buffer or - /// cached - /// - /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. - /// - /// Includes [bufferedTiles]. - final int cachedTiles; - - /// The total size (in KiB) of new tiles successfully downloaded and in the - /// tile buffer or cached - /// - /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. - /// - /// Includes [bufferedSize]. - final double cachedSize; - - /// The number of new tiles successfully downloaded and in the tile buffer - /// waiting to be cached - /// - /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. - /// - /// Part of [cachedTiles]. - final int bufferedTiles; - - /// The total size (in KiB) of new tiles successfully downloaded and in the - /// tile buffer waiting to be cached - /// - /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. - /// - /// Part of [cachedSize]. - final double bufferedSize; - - /// The number of tiles that were skipped (not cached) because they either: - /// - already existed & `skipExistingTiles` was `true` - /// - were a sea tile & `skipSeaTiles` was `true` - /// - /// [TileEvent]s with the result category of [TileEventResultCategory.skipped]. - final int skippedTiles; - - /// 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` - /// - /// [TileEvent]s with the result category of [TileEventResultCategory.skipped]. - final double skippedSize; - - /// The number of tiles that were not successfully downloaded, potentially for - /// a variety of reasons - /// - /// [TileEvent]s with the result category of [TileEventResultCategory.failed]. - /// - /// To check why these tiles failed, use [latestTileEvent] to construct a list - /// of tiles that failed. - final int failedTiles; - - /// 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 current elapsed duration of the download - /// - /// Will be accurate to within `maxReportInterval` or better. - final Duration elapsedDuration; - - /// The approximate/estimated number of attempted tiles per second (TPS) - /// - /// Note that this value is not raw. It goes through multiple layers of - /// smoothing which takes into account more than just the previous second. - /// It may or may not be accurate. - final double tilesPerSecond; - - /// Whether the number of [tilesPerSecond] could be higher, but is currently - /// 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; - - /// The number of tiles that were either cached, in buffer, or skipped - /// - /// Equal to [cachedTiles] + [skippedTiles]. - int get successfulTiles => cachedTiles + skippedTiles; - - /// The total size (in KiB) of tiles that were either cached, in buffer, or - /// skipped - /// - /// Equal to [cachedSize] + [skippedSize]. - double get successfulSize => cachedSize + skippedSize; - - /// The number of tiles that have been attempted, with any result - /// - /// 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 - /// - /// Equal to [attemptedTiles] / [maxTiles] multiplied by 100. - double get percentageProgress => (attemptedTiles / maxTiles) * 100; - - /// 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. - /// - /// It may or may not be accurate. - /// - /// 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._repeat(), - 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, - }) => - 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, - ); - - @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 && - elapsedDuration == other.elapsedDuration && - tilesPerSecond == other.tilesPerSecond && - isTPSArtificiallyCapped == other.isTPSArtificiallyCapped && - isComplete == other.isComplete); - - @override - int get hashCode => Object.hashAllUnordered([ - _latestTileEvent, - cachedTiles, - cachedSize, - bufferedTiles, - bufferedSize, - skippedTiles, - skippedSize, - failedTiles, - maxTiles, - elapsedDuration, - tilesPerSecond, - isTPSArtificiallyCapped, - isComplete, - ]); -} diff --git a/lib/src/bulk_download/external/download_progress.dart b/lib/src/bulk_download/external/download_progress.dart new file mode 100644 index 00000000..853efbdd --- /dev/null +++ b/lib/src/bulk_download/external/download_progress.dart @@ -0,0 +1,391 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// Statistics and information about the current progress of the download +/// +/// See the documentation on each individual property for more information. +@immutable +class DownloadProgress { + /// 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.successfulTilesSize, + required this.successfulTilesCount, + 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 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, + }) : successfulTilesCount = 0, + successfulTilesSize = 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; + + /// Create a new progress object based on the existing one, due to a new tile + /// event + /// + /// [newTileEvent] should be provided for non-[SuccessfulTileEvent]s: this + /// will be used to automatically update all neccessary statistics. + /// + /// For [SuccessfulTileEvent]s, the flushed and buffered metrics cannot be + /// automatically updated from information in the tile event alone. + /// [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})? bufferedTiles, + required Duration elapsedDuration, + required double tilesPerSecond, + }) => + DownloadProgress._( + 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), + 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, + ); + + /// Create a new progress object based on the existing one, without a new tile + DownloadProgress _updateWithoutTile({ + required Duration elapsedDuration, + required double tilesPerSecond, + }) => + DownloadProgress._( + successfulTilesCount: successfulTilesCount, + successfulTilesSize: successfulTilesSize, + 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, + ); + + /// 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. + int get remainingTilesCount => + maxTilesCount - (attemptedTilesCount - retryTilesQueuedCount); + + /// The number of tiles that have been attempted to download + /// + /// Attempted means they were successful ([successfulTilesCount]), skipped + /// ([skippedTilesCount]), or failed ([failedTilesCount]). Additionally, this + /// also includes [retryTilesQueuedCount]. + int get attemptedTilesCount => + successfulTilesCount + + skippedTilesCount + + failedTilesCount + + retryTilesQueuedCount; + + /// The number of tiles successfully downloaded (including both tiles buffered + /// and actually flushed/written to cache) + /// + /// This is the number of [SuccessfulTileEvent]s emitted. + final int successfulTilesCount; + + /// The size in KiB of the tile images successfully downloaded (including both + /// tiles buffered and actually flushed/written to cache) + final double successfulTilesSize; + + /// The number of tiles successfully downloaded and written to the cache + /// (flushed from the buffer) + int get flushedTilesCount => successfulTilesCount - bufferedTilesCount; + + /// The size in KiB of the tile images successfully downloaded and written to + /// the cache (flushed from the buffer) + double get flushedTilesSize => successfulTilesSize - bufferedTilesSize; + + /// The number of tiles successfully downloaded but still to be written to the + /// cache + /// + /// 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 size in KiB of the tile images successfully downloaded but still to be + /// written to the cache + /// + /// 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) + /// + /// 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 number of tiles skipped because they were sea tiles and `skipSeaTiles` + /// was enabled + /// + /// 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 + /// + /// This is the number of [ExistingTileEvent]s emitted. + final int existingTilesCount; + + /// 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]) + /// + /// See [failedRequestTilesCount] for more information about how that metric + /// is affected by retry tiles. + int get failedTilesCount => + negativeResponseTilesCount + failedRequestTilesCount; + + /// The number of tiles that could not be downloaded because the HTTP response + /// was not 200 OK + /// + /// This is the number of [NegativeResponseTileEvent]s emitted. + final int negativeResponseTilesCount; + + /// The number of tiles that could not be downloaded because the HTTP request + /// could not be made + /// + /// 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 + /// + /// 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] 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 + /// + /// Will be accurate to within `maxReportInterval` or better. + final Duration elapsedDuration; + + /// The approximate/estimated number of attempted tiles per second (TPS) + /// + /// Note that this value is not raw. It goes through multiple layers of + /// smoothing which takes into account more than just the previous second. + /// It may or may not be accurate. + final double tilesPerSecond; + + /// Whether the number of [tilesPerSecond] could be higher, but is currently + /// capped by the set `rateLimit` + /// + /// This is only an approximate indicator. + bool get isTPSArtificiallyCapped => + _tilesPerSecondLimit != null && + (tilesPerSecond >= _tilesPerSecondLimit - 0.5); + + /// The percentage [attemptedTilesCount] is of [maxTilesCount] (expressed + /// from 0 - 100) + double get percentageProgress => (attemptedTilesCount / maxTilesCount) * 100; + + /// The estimated total duration of the download + /// + /// If the [tilesPerSecond] is 0 or very small, then the reported duration + /// is [Duration.zero]. + /// + /// No accuracy guarantees are given. Precision to 1 second. + /// + /// > [!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 remaining duration of the download + /// + /// No accuracy guarantees are given. + /// + /// > [!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 && + successfulTilesCount == other.successfulTilesCount && + successfulTilesSize == other.successfulTilesSize && + 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 && + _tilesPerSecondLimit == other._tilesPerSecondLimit); + + @override + int get hashCode => Object.hashAllUnordered([ + successfulTilesCount, + successfulTilesSize, + bufferedTilesCount, + bufferedTilesSize, + seaTilesCount, + seaTilesSize, + existingTilesCount, + existingTilesSize, + negativeResponseTilesCount, + failedRequestTilesCount, + retryTilesQueuedCount, + maxTilesCount, + elapsedDuration, + tilesPerSecond, + _tilesPerSecondLimit, + ]); +} diff --git a/lib/src/bulk_download/external/tile_event.dart b/lib/src/bulk_download/external/tile_event.dart new file mode 100644 index 00000000..b4670f19 --- /dev/null +++ b/lib/src/bulk_download/external/tile_event.dart @@ -0,0 +1,203 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// The result of a tile download during bulk downloading +/// +/// Does not contain information about the download as a whole, that is +/// [DownloadProgress]' scope. +/// +/// See specific subclasses for more information about the event. This is a +/// sealed tree, so there are a guaranteed knowable set of results. +@immutable +sealed class TileEvent { + const TileEvent._({ + required this.url, + required this.coordinates, + required this.wasRetryAttempt, + }); + + /// The URL used to request the tile + final String url; + + /// The (x, y, z) coordinates of this tile + final (int, int, int) coordinates; + + /// Whether this tile was a retry attempt of a [FailedRequestTileEvent] + /// + /// Never set if `retryFailedRequestTiles` is disabled. + /// + /// Implies that the tile has been emitted before. Care should be taken to + /// ensure that this does not cause issues (for example, duplication issues). + /// + /// (This is also used internally to maintain [DownloadProgress] statistics.) + final bool wasRetryAttempt; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TileEvent && + url == other.url && + coordinates == other.coordinates && + 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 + 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/control_cmds.dart b/lib/src/bulk_download/internal/control_cmds.dart new file mode 100644 index 00000000..90cea268 --- /dev/null +++ b/lib/src/bulk_download/internal/control_cmds.dart @@ -0,0 +1,14 @@ +// 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, + startEmittingDownloadProgress, + stopEmittingDownloadProgress, + startEmittingTileEvents, + stopEmittingTileEvents, +} diff --git a/lib/src/bulk_download/instance.dart b/lib/src/bulk_download/internal/instance.dart similarity index 70% rename from lib/src/bulk_download/instance.dart rename to lib/src/bulk_download/internal/instance.dart index ea8111af..6a0bc21f 100644 --- a/lib/src/bulk_download/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 @@ -15,16 +17,13 @@ class DownloadInstance { final Object id; - Future Function()? requestCancel; - 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 + 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 new file mode 100644 index 00000000..62dd3e69 --- /dev/null +++ b/lib/src/bulk_download/internal/manager.dart @@ -0,0 +1,476 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +Future _downloadManager( + ({ + SendPort sendPort, + DownloadableRegion region, + String storeName, + int parallelThreads, + int maxBufferLength, + bool skipExistingTiles, + bool skipSeaTiles, + Duration? maxReportInterval, + int? rateLimit, + bool retryFailedRequestTiles, + String Function(String) urlTransformer, + int? recoveryId, + FMTCBackendInternalThreadSafe backend, + }) input, +) async { + // Precalculate how large the tile buffers should be for each thread + final threadBufferLength = + (input.maxBufferLength / input.parallelThreads).floor(); + + // Generate appropriate headers for network requests + final inputHeaders = input.region.options.tileProvider.headers; + final headers = { + ...inputHeaders, + 'User-Agent': inputHeaders['User-Agent'] == null + ? 'flutter_map (unknown)' + : 'flutter_map + FMTC ${inputHeaders['User-Agent']!.replaceRange( + 0, + inputHeaders['User-Agent']!.length.clamp(0, 12), + '', + )}', + }; + + // Count number of tiles + final tilesCount = input.region.when( + rectangle: TileCounters.rectangleTiles, + circle: TileCounters.circleTiles, + line: TileCounters.lineTiles, + customPolygon: TileCounters.customPolygonTiles, + multi: TileCounters.multiTiles, + ); + + // Setup sea tile removal system + Uint8List? seaTileBytes; + if (input.skipSeaTiles) { + try { + seaTileBytes = await http.readBytes( + Uri.parse( + input.region.options.tileProvider.getTileUrl( + const TileCoordinates(0, 0, 17), + input.region.options, + ), + ), + headers: headers, + ); + // We don't care about the exact error + // ignore: avoid_catches_without_on_clauses + } catch (_) { + seaTileBytes = null; + } + } + + // Setup thread buffer tracking + late final List threadBuffersSize; + late final List threadBuffersTiles; + if (input.maxBufferLength != 0) { + threadBuffersSize = List.filled(input.parallelThreads, 0); + threadBuffersTiles = List.filled(input.parallelThreads, 0); + } + + // Setup tile generation + final tileReceivePort = ReceivePort(); + final tileIsolate = await Isolate.spawn( + (({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', + ); + + // 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 + ? mergedTileStreams + : mergedTileStreams.rateLimit( + minimumSpacing: Duration( + microseconds: ((1 / input.rateLimit!) * 1000000).ceil(), + ), + ), + ); + final requestTilePort = await tileQueue.next as SendPort; + + // Start progress tracking + final initialDownloadProgress = DownloadProgress._initial( + maxTilesCount: tilesCount, + tilesPerSecondLimit: input.rateLimit, + retryFailedRequestTiles: input.retryFailedRequestTiles, + ); + var lastDownloadProgress = initialDownloadProgress; + final downloadDuration = Stopwatch(); + final tileCompletionTimestamps = []; + const tpsSmoothingFactor = 0.5; + final tpsSmoothingStorage = [null]; + int currentTPSSmoothingIndex = 0; + double getCurrentTPS({required bool registerNewTPS}) { + if (registerNewTPS) tileCompletionTimestamps.add(DateTime.timestamp()); + tileCompletionTimestamps.removeWhere( + (e) => + e.isBefore(DateTime.timestamp().subtract(const Duration(seconds: 1))), + ); + currentTPSSmoothingIndex++; + tpsSmoothingStorage[currentTPSSmoothingIndex % tpsSmoothingStorage.length] = + tileCompletionTimestamps.length; + final tps = tpsSmoothingStorage.nonNulls.average; + tpsSmoothingStorage.length = + (tps * tpsSmoothingFactor).ceil().clamp(1, 1000); + return tps; + } + + // Setup two-way communications with root + final rootReceivePort = ReceivePort(); + void sendToRoot(Object? m) => input.sendPort.send(m); + + // Setup cancel, pause, and resume handling + Iterable> generateThreadPausedStates() => Iterable.generate( + input.parallelThreads, + (_) => Completer(), + ); + 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: + // We might recieve it more than once if the root requests + // cancellation whilst we already are cancelling it + if (!cancelSignal.isCompleted) cancelSignal.complete(); + case _DownloadManagerControlCmd.pause: + // We are already pausing or paused + if (!pauseResumeSignal.isCompleted) return; + + pauseResumeSignal = Completer(); + threadPausedStates.setAll(0, generateThreadPausedStates()); + await Future.wait(threadPausedStates.map((e) => e.future)); + + downloadDuration.stop(); + fallbackProgressEmitter?.cancel(); + if (shouldEmitDownloadProgress) emitLastDownloadProgressUpdated(); + + sendToRoot(_DownloadManagerControlCmd.pause); + case _DownloadManagerControlCmd.resume: + if (shouldEmitDownloadProgress) restartFallbackProgressEmitter(); + downloadDuration.start(); + 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; + } + }, + ); + + // Start recovery system (unless disabled) + if (input.recoveryId case final recoveryId?) { + await input.backend.initialise(); + await input.backend.startRecovery( + id: recoveryId, + storeName: input.storeName, + region: input.region, + tilesCount: tilesCount, + ); + } + + // Create convienience method to update recovery system if enabled + void updateRecovery() { + if (input.recoveryId case final recoveryId?) { + input.backend.updateRecovery( + id: recoveryId, + newStartTile: + input.region.start + lastDownloadProgress.flushedTilesCount, + ); + } + } + + // Duplicate the backend to make it safe to send through isolates + final threadBackend = input.backend.duplicate(); + + // Now it's safe, start accepting communications from the root + sendToRoot(rootReceivePort.sendPort); + + // Send an initial progress report to indicate the start of the download + // 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(); + await Future.wait( + List.generate( + input.parallelThreads, + (threadNo) async { + if (cancelSignal.isCompleted) return; + + // Start thread worker isolate & setup two-way communications + final downloadThreadReceivePort = ReceivePort(); + await Isolate.spawn( + _singleDownloadThread, + ( + sendPort: downloadThreadReceivePort.sendPort, + storeName: input.storeName, + options: input.region.options, + maxBufferLength: threadBufferLength, + skipExistingTiles: input.skipExistingTiles, + seaTileBytes: seaTileBytes, + urlTransformer: input.urlTransformer, + headers: headers, + backend: threadBackend, + ), + onExit: downloadThreadReceivePort.sendPort, + debugName: '[FMTC] Bulk Download Thread #$threadNo', + ); + late final SendPort sendPort; + final sendPortCompleter = Completer(); + + // Prevent completion of this function until the thread is shutdown + final threadKilled = Completer(); + + // When one thread is complete, or the manual cancel signal is sent, + // kill all threads + unawaited( + cancelSignal.future + // Handles case when cancel is emitted before thread is setup + .then((_) => sendPortCompleter.future) + .then((s) => s.send(null)), + ); + + downloadThreadReceivePort.listen( + (evt) async { + // Thread is sending tile data + if (evt is TileEvent) { + // 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 && + 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) { + // Update correct thread buffer with new tile on success + if (evt is SuccessfulTileEvent) { + if (evt._wasBufferFlushed) { + threadBuffersTiles[threadNo] = 0; + threadBuffersSize[threadNo] = 0; + } else { + threadBuffersTiles[threadNo]++; + threadBuffersSize[threadNo] += evt.tileImage.lengthInBytes; + } + } + + // 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 (evt is SuccessfulTileEvent && evt._wasBufferFlushed) { + updateRecovery(); + } + } else { + // We do not need to care about buffering, which makes updates + // much easier + + lastDownloadProgress = lastDownloadProgress._updateWithTile( + newTileEvent: evt, + elapsedDuration: downloadDuration.elapsed, + tilesPerSecond: getCurrentTPS(registerNewTPS: true), + ); + if (shouldEmitDownloadProgress) { + sendToRoot(lastDownloadProgress); + } + + updateRecovery(); + } + + return; + } + + // 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); + + // 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 + if (evt is SendPort) { + sendPortCompleter.complete(evt); + sendPort = evt; + return; + } + + // Thread ended, goto `onDone` + if (evt == null) return downloadThreadReceivePort.close(); + }, + onDone: () { + try { + cancelSignal.complete(); + // If the signal is already complete, that's fine + // ignore: avoid_catching_errors, empty_catches + } on StateError {} + + threadKilled.complete(); + }, + ); + + // Prevent completion of this function until the thread is shutdown + await threadKilled.future; + }, + growable: false, + ), + ); + + // Send final progress update + downloadDuration.stop(); + lastDownloadProgress = lastDownloadProgress._updateToComplete( + elapsedDuration: downloadDuration.elapsed, + ); + if (shouldEmitDownloadProgress) sendToRoot(lastDownloadProgress); + + // Cleanup resources and shutdown + fallbackProgressEmitter?.cancel(); + rootReceivePort.close(); + if (input.recoveryId != null) await input.backend.uninitialise(); + tileIsolate.kill(priority: Isolate.immediate); + await tileQueue.cancel(immediate: true); + Isolate.exit(); +} diff --git a/lib/src/bulk_download/rate_limited_stream.dart b/lib/src/bulk_download/internal/rate_limited_stream.dart similarity index 94% rename from lib/src/bulk_download/rate_limited_stream.dart rename to lib/src/bulk_download/internal/rate_limited_stream.dart index 02665f19..a546c735 100644 --- a/lib/src/bulk_download/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/thread.dart b/lib/src/bulk_download/internal/thread.dart similarity index 55% rename from lib/src/bulk_download/thread.dart rename to lib/src/bulk_download/internal/thread.dart index 0454eba9..626bd061 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( ({ @@ -11,7 +11,7 @@ Future _singleDownloadThread( int maxBufferLength, bool skipExistingTiles, Uint8List? seaTileBytes, - Iterable obscuredQueryParams, + String Function(String) urlTransformer, Map headers, FMTCBackendInternalThreadSafe backend, }) input, @@ -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 = []; @@ -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,50 +61,54 @@ 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 = obscureQueryParams( - url: networkUrl, - obscuredQueryParams: input.obscuredQueryParams, - ); - - 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 - final http.Response response; + final Response response; try { response = await httpClient.get(Uri.parse(networkUrl), headers: input.headers); - } catch (e) { + // We don't care about the exact error + // ignore: avoid_catches_without_on_clauses + } 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; @@ -109,11 +116,11 @@ Future _singleDownloadThread( if (response.statusCode != 200) { send( - TileEvent._( - TileEventResult.negativeFetchResponse, + NegativeResponseTileEvent._( url: networkUrl, - coordinates: coordinates, + coordinates: tileCoordinates, fetchResponse: response, + wasRetryAttempt: isRetryAttempt, ), ); continue; @@ -122,12 +129,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; @@ -146,8 +153,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, @@ -159,13 +168,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/bulk_download/tile_loops/count.dart b/lib/src/bulk_download/internal/tile_loops/count.dart similarity index 73% rename from lib/src/bulk_download/tile_loops/count.dart rename to lib/src/bulk_download/internal/tile_loops/count.dart index f4c5ae39..be2075fe 100644 --- a/lib/src/bulk_download/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]. @@ -24,22 +20,20 @@ part of 'shared.dart'; /// automated tests. @internal class TileCounters { - /// Trim [numOfTiles] to between the [region]'s [DownloadableRegion.start] and + /// Trim [tileCount] to between the [region]'s [DownloadableRegion.start] and /// [DownloadableRegion.end] - static int _trimToRange(DownloadableRegion region, int numOfTiles) => - min(region.end ?? largestInt, numOfTiles) - - min(region.start - 1, numOfTiles); + static int _trimToRange(DownloadableRegion region, int tileCount) => + 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) { - region as DownloadableRegion; - + static int rectangleTiles(DownloadableRegion region) { final northWest = region.originalRegion.bounds.northWest; final southEast = region.originalRegion.bounds.southEast; - var numberOfTiles = 0; + var tileCount = 0; for (double zoomLvl = region.minZoom.toDouble(); zoomLvl <= region.maxZoom; @@ -52,63 +46,62 @@ class TileCounters { .ceil() - const Point(1, 1); - numberOfTiles += - (sePoint.x - nwPoint.x + 1) * (sePoint.y - nwPoint.y + 1); + tileCount += (sePoint.x - nwPoint.x + 1) * (sePoint.y - nwPoint.y + 1); } - return _trimToRange(region, numberOfTiles); + 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) { - region as DownloadableRegion; + static int circleTiles(DownloadableRegion region) { + int tileCount = 0; - final circleOutline = region.originalRegion.toOutline(); + final edgeTile = const Distance(roundResult: false).offset( + region.originalRegion.center, + region.originalRegion.radius * 1000, + 0, + ); - // Format: Map>> - final outlineTileNums = >>{}; + for (int zoomLvl = region.minZoom; zoomLvl <= region.maxZoom; zoomLvl++) { + final centerTile = (region.crs.latLngToPoint( + region.originalRegion.center, + zoomLvl.toDouble(), + ) / + region.options.tileSize) + .floor(); - int numberOfTiles = 0; + final radius = centerTile.y - + (region.crs.latLngToPoint(edgeTile, zoomLvl.toDouble()) / + region.options.tileSize) + .floor() + .y; - for (int zoomLvl = region.minZoom; zoomLvl <= region.maxZoom; zoomLvl++) { - outlineTileNums[zoomLvl] = {}; + final radiusSquared = radius * radius; - for (final node in circleOutline) { - final tile = (region.crs.latLngToPoint(node, zoomLvl.toDouble()) / - region.options.tileSize) - .floor(); + if (radius == 0) { + tileCount++; + continue; + } - 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], - ]; + if (radius == 1) { + tileCount += 4; + continue; } - for (final x in outlineTileNums[zoomLvl]!.keys) { - numberOfTiles += outlineTileNums[zoomLvl]![x]![1] - - outlineTileNums[zoomLvl]![x]![0] + - 1; + for (int dy = 0; dy < radius; dy++) { + tileCount += (4 * sqrt(radiusSquared - dy * dy).floor()) + 4; } } - return _trimToRange(region, numberOfTiles); + 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) { - 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) { @@ -147,7 +140,7 @@ class TileCounters { final lineOutline = region.originalRegion.toOutlines(1); - int numberOfTiles = 0; + int tileCount = 0; for (double zoomLvl = region.minZoom.toDouble(); zoomLvl <= region.maxZoom; @@ -231,7 +224,7 @@ class TileCounters { ), tile, )) { - numberOfTiles++; + tileCount++; generatedTiles.add(tile.hashCode); foundOverlappingTile = true; } else if (foundOverlappingTile) { @@ -242,18 +235,18 @@ class TileCounters { } } - return _trimToRange(region, numberOfTiles); + 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) { - region as DownloadableRegion; - + static int customPolygonTiles( + DownloadableRegion region, + ) { final customPolygonOutline = region.originalRegion.outline; - int numberOfTiles = 0; + int tileCount = 0; for (double zoomLvl = region.minZoom.toDouble(); zoomLvl <= region.maxZoom; @@ -301,13 +294,38 @@ class TileCounters { for (; xs.contains(xsRawMax - i); i++) {} final xsMax = xsRawMax - i; - if (xsMin <= xsMax) numberOfTiles += (xsMax - xsMin) + 1; + if (xsMin <= xsMax) tileCount += (xsMax - xsMin) + 1; } } - numberOfTiles += allOutlineTiles.length; + tileCount += allOutlineTiles.length; } - return _trimToRange(region, numberOfTiles); + return _trimToRange(region, tileCount); } + + /// 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/tile_loops/generate.dart b/lib/src/bulk_download/internal/tile_loops/generate.dart similarity index 53% rename from lib/src/bulk_download/tile_loops/generate.dart rename to lib/src/bulk_download/internal/tile_loops/generate.dart index 430491ca..459042ab 100644 --- a/lib/src/bulk_download/tile_loops/generate.dart +++ b/lib/src/bulk_download/internal/tile_loops/generate.dart @@ -3,17 +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] -/// -/// 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`. +/// 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. @@ -27,16 +23,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; @@ -55,96 +60,180 @@ 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) { + 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 { - // 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 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 = {}; + ({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; final end = (region.end ?? double.infinity) - 1; + 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) continue; + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + + await requestQueue.next; + 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; + if (radius == 1) { + tileCounter++; + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + + await requestQueue.next; + sendPort.send((centerTile.x, centerTile.y, zoomLvl)); + } + + tileCounter++; + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; - input.sendPort.send((x, y, zoomLvl)); + sendPort.send((centerTile.x, centerTile.y - 1, zoomLvl)); + } + + tileCounter++; + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + + await requestQueue.next; + sendPort.send((centerTile.x - 1, centerTile.y, zoomLvl)); + } + + tileCounter++; + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + + await requestQueue.next; + sendPort.send((centerTile.x - 1, centerTile.y - 1, zoomLvl)); + } + + continue; + } + + 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) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + + await requestQueue.next; + sendPort.send((dx + centerTile.x, dy + centerTile.y, zoomLvl)); + } + + tileCounter++; + if (tileCounter >= start) { + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + + await requestQueue.next; + 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 { - // 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 + ({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 // Overlap algorithm originally in Python, available at https://stackoverflow.com/a/56962827/11846040 bool overlap(_Polygon a, _Polygon b) { @@ -181,12 +270,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; @@ -258,8 +355,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 || tileCounter > end) continue; final tile = _Polygon( Point(x, y), Point(x + 1, y), @@ -267,6 +362,7 @@ class TileGenerators { Point(x, y + 1), ); if (generatedTiles.contains(tile.hashCode)) continue; + if (overlap( _Polygon( rotatedRectangleNW, @@ -278,8 +374,16 @@ class TileGenerators { )) { generatedTiles.add(tile.hashCode); foundOverlappingTile = true; + + tileCounter++; + if (tileCounter < start) continue; + if (tileCounter > end) { + if (!inMulti) Isolate.exit(); + return; + } + await requestQueue.next; - input.sendPort.send((x, y, zoomLvl.toInt())); + sendPort.send((x, y, zoomLvl.toInt())); } else if (foundOverlappingTile) { break; } @@ -288,21 +392,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; @@ -313,7 +427,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( @@ -355,20 +469,88 @@ 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; - input.sendPort.send((x, y, zoomLvl.toInt())); + sendPort.send((x, y, zoomLvl.toInt())); } } } for (final Point(:x, :y) in allOutlineTiles) { tileCounter++; - if (tileCounter < start || tileCounter > end) continue; + if (tileCounter < start) continue; + 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/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..d2f3004d 100644 --- a/lib/src/bulk_download/tile_loops/shared.dart +++ b/lib/src/bulk_download/internal/tile_loops/shared.dart @@ -12,12 +12,13 @@ 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'; +@immutable class _Polygon { _Polygon(Point nw, Point ne, Point se, Point sw) : points = [nw, ne, se, sw] { diff --git a/lib/src/bulk_download/manager.dart b/lib/src/bulk_download/manager.dart deleted file mode 100644 index 867aa177..00000000 --- a/lib/src/bulk_download/manager.dart +++ /dev/null @@ -1,357 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of '../../flutter_map_tile_caching.dart'; - -Future _downloadManager( - ({ - SendPort sendPort, - DownloadableRegion region, - String storeName, - int parallelThreads, - int maxBufferLength, - bool skipExistingTiles, - bool skipSeaTiles, - Duration? maxReportInterval, - int? rateLimit, - List obscuredQueryParams, - int? recoveryId, - FMTCBackendInternalThreadSafe backend, - }) input, -) async { - // Precalculate how large the tile buffers should be for each thread - final threadBufferLength = - (input.maxBufferLength / input.parallelThreads).floor(); - - // Generate appropriate headers for network requests - final inputHeaders = input.region.options.tileProvider.headers; - final headers = { - ...inputHeaders, - 'User-Agent': inputHeaders['User-Agent'] == null - ? 'flutter_map (unknown)' - : 'flutter_map + FMTC ${inputHeaders['User-Agent']!.replaceRange( - 0, - inputHeaders['User-Agent']!.length.clamp(0, 12), - '', - )}', - }; - - // Count number of tiles - final maxTiles = input.region.when( - rectangle: TileCounters.rectangleTiles, - circle: TileCounters.circleTiles, - line: TileCounters.lineTiles, - customPolygon: TileCounters.customPolygonTiles, - ); - - // Setup sea tile removal system - Uint8List? seaTileBytes; - if (input.skipSeaTiles) { - try { - seaTileBytes = await http.readBytes( - Uri.parse( - input.region.options.tileProvider.getTileUrl( - const TileCoordinates(0, 0, 17), - input.region.options, - ), - ), - headers: headers, - ); - } catch (_) { - seaTileBytes = null; - } - } - - // Setup thread buffer tracking - late final List<({double size, int tiles})> threadBuffers; - if (input.maxBufferLength != 0) { - threadBuffers = List.generate( - input.parallelThreads, - (_) => (tiles: 0, size: 0.0), - growable: false, - ); - } - - // 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: tileReceivePort.sendPort, region: input.region), - onExit: tileReceivePort.sendPort, - debugName: '[FMTC] Tile Coords Generator Thread', - ); - final tileQueue = StreamQueue( - input.rateLimit == null - ? tileReceivePort - : tileReceivePort.rateLimit( - minimumSpacing: Duration( - microseconds: ((1 / input.rateLimit!) * 1000000).ceil(), - ), - ), - ); - final requestTilePort = await tileQueue.next as SendPort; - - // Start progress tracking - final initialDownloadProgress = DownloadProgress._initial(maxTiles: maxTiles); - var lastDownloadProgress = initialDownloadProgress; - final downloadDuration = Stopwatch(); - final tileCompletionTimestamps = []; - const tpsSmoothingFactor = 0.5; - final tpsSmoothingStorage = [null]; - int currentTPSSmoothingIndex = 0; - double getCurrentTPS({required bool registerNewTPS}) { - if (registerNewTPS) tileCompletionTimestamps.add(DateTime.timestamp()); - tileCompletionTimestamps.removeWhere( - (e) => - e.isBefore(DateTime.timestamp().subtract(const Duration(seconds: 1))), - ); - currentTPSSmoothingIndex++; - tpsSmoothingStorage[currentTPSSmoothingIndex % tpsSmoothingStorage.length] = - tileCompletionTimestamps.length; - final tps = tpsSmoothingStorage.nonNulls.average; - tpsSmoothingStorage.length = - (tps * tpsSmoothingFactor).ceil().clamp(1, 1000); - return tps; - } - - // Setup two-way communications with root - final rootReceivePort = ReceivePort(); - void send(Object? m) => input.sendPort.send(m); - - // Setup cancel, pause, and resume handling - List> generateThreadPausedStates() => List.generate( - input.parallelThreads, - (_) => Completer(), - growable: false, - ); - final threadPausedStates = generateThreadPausedStates(); - final cancelSignal = Completer(); - var pauseResumeSignal = Completer()..complete(); - rootReceivePort.listen( - (e) async { - if (e == null) { - try { - cancelSignal.complete(); - // ignore: avoid_catching_errors, empty_catches - } on StateError {} - } else if (e == 1) { - pauseResumeSignal = Completer(); - threadPausedStates.setAll(0, generateThreadPausedStates()); - await Future.wait(threadPausedStates.map((e) => e.future)); - downloadDuration.stop(); - send(1); - } else if (e == 2) { - pauseResumeSignal.complete(); - downloadDuration.start(); - } - }, - ); - - // Setup progress report fallback - final fallbackReportTimer = input.maxReportInterval == null - ? null - : Timer.periodic( - input.maxReportInterval!, - (_) { - if (lastDownloadProgress != initialDownloadProgress && - pauseResumeSignal.isCompleted) { - send( - lastDownloadProgress = - lastDownloadProgress._fallbackReportUpdate( - newDuration: downloadDuration.elapsed, - tilesPerSecond: getCurrentTPS(registerNewTPS: false), - rateLimit: input.rateLimit, - ), - ); - } - }, - ); - - // Start recovery system (unless disabled) - if (input.recoveryId case final recoveryId?) { - await input.backend.initialise(); - await input.backend.startRecovery( - id: recoveryId, - storeName: input.storeName, - region: input.region, - endTile: min(input.region.end ?? largestInt, maxTiles), - ); - send(2); - } - - // Duplicate the backend to make it safe to send through isolates - final threadBackend = input.backend.duplicate(); - - // Now it's safe, start accepting communications from the root - send(rootReceivePort.sendPort); - - // Start download threads & wait for download to complete/cancelled - downloadDuration.start(); - await Future.wait( - List.generate( - input.parallelThreads, - (threadNo) async { - if (cancelSignal.isCompleted) return; - - // Start thread worker isolate & setup two-way communications - final downloadThreadReceivePort = ReceivePort(); - await Isolate.spawn( - _singleDownloadThread, - ( - sendPort: downloadThreadReceivePort.sendPort, - storeName: input.storeName, - options: input.region.options, - maxBufferLength: threadBufferLength, - skipExistingTiles: input.skipExistingTiles, - seaTileBytes: seaTileBytes, - obscuredQueryParams: input.obscuredQueryParams, - headers: headers, - backend: threadBackend, - ), - onExit: downloadThreadReceivePort.sendPort, - debugName: '[FMTC] Bulk Download Thread #$threadNo', - ); - late final SendPort sendPort; - final sendPortCompleter = Completer(); - - // Prevent completion of this function until the thread is shutdown - final threadKilled = Completer(); - - // When one thread is complete, or the manual cancel signal is sent, - // kill all threads - unawaited( - cancelSignal.future - .then((_) => sendPortCompleter.future) - .then((sp) => sp.send(null)), - ); - - downloadThreadReceivePort.listen( - (evt) async { - // Thread is sending tile data - if (evt is TileEvent) { - // If buffering is in use, send a progress update with buffer info - if (input.maxBufferLength != 0) { - 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) - ); - } - - 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), - newDuration: downloadDuration.elapsed, - tilesPerSecond: getCurrentTPS(registerNewTPS: true), - rateLimit: input.rateLimit, - ), - ); - } else { - send( - lastDownloadProgress = - lastDownloadProgress._updateProgressWithTile( - newTileEvent: evt, - newBufferedTiles: 0, - newBufferedSize: 0, - newDuration: downloadDuration.elapsed, - tilesPerSecond: getCurrentTPS(registerNewTPS: true), - rateLimit: input.rateLimit, - ), - ); - } - - if (input.recoveryId case final recoveryId?) { - input.backend.updateRecovery( - id: recoveryId, - newStartTile: 1 + - (lastDownloadProgress.cachedTiles - - lastDownloadProgress.bufferedTiles), - ); - } - - return; - } - - // Thread is requesting new tile coords - if (evt is int) { - if (!pauseResumeSignal.isCompleted) { - threadPausedStates[threadNo].complete(); - await pauseResumeSignal.future; - } - - requestTilePort.send(null); - try { - sendPort.send(await tileQueue.next); - // ignore: avoid_catching_errors - } on StateError { - sendPort.send(null); - } - return; - } - - // Thread is establishing comms - if (evt is SendPort) { - sendPortCompleter.complete(evt); - sendPort = evt; - return; - } - - // Thread ended, goto `onDone` - if (evt == null) return downloadThreadReceivePort.close(); - }, - onDone: () { - try { - cancelSignal.complete(); - // ignore: avoid_catching_errors, empty_catches - } on StateError {} - - threadKilled.complete(); - }, - ); - - // Prevent completion of this function until the thread is shutdown - await threadKilled.future; - }, - growable: false, - ), - ); - 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 - rootReceivePort.close(); - if (input.recoveryId != null) await input.backend.uninitialise(); - tileIsolate.kill(priority: Isolate.immediate); - await tileQueue.cancel(immediate: true); - Isolate.exit(); -} diff --git a/lib/src/bulk_download/tile_event.dart b/lib/src/bulk_download/tile_event.dart deleted file mode 100644 index 07660bbd..00000000 --- a/lib/src/bulk_download/tile_event.dart +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -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 -/// -/// Does not contain information about the download as a whole, that is -/// [DownloadProgress]' responsibility. -/// -/// {@template fmtc.tileevent.extraConsiderations} -/// > [!TIP] -/// > 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} -@immutable -class TileEvent { - const TileEvent._( - this.result, { - 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; - - /// 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 [http.Response] from the [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; - - /// The raw error thrown when fetching from the [url], if available - /// - /// Only available if [result] is [TileEventResult.noConnectionDuringFetch] or - /// [TileEventResult.unknownFetchException]. - /// - /// Remember to check [isRepeat] before keeping track of this value. - final Object? fetchError; - - /// Whether this event is a repeat of the last event - /// - /// 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 _repeat() => TileEvent._( - result, - url: url, - coordinates: coordinates, - tileImage: tileImage, - fetchResponse: fetchResponse, - fetchError: fetchError, - isRepeat: true, - wasBufferReset: _wasBufferReset, - ); - - @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); - - @override - int get hashCode => Object.hashAllUnordered([ - result, - url, - coordinates, - tileImage, - fetchResponse, - fetchError, - isRepeat, - _wasBufferReset, - ]); -} 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/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/image_provider.dart b/lib/src/providers/image_provider.dart deleted file mode 100644 index fac44747..00000000 --- a/lib/src/providers/image_provider.dart +++ /dev/null @@ -1,292 +0,0 @@ -// 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'; - -/// A specialised [ImageProvider] that uses FMTC internals to enable browse -/// caching -class FMTCImageProvider extends ImageProvider { - /// Create a specialised [ImageProvider] that uses FMTC internals to enable - /// browse caching - FMTCImageProvider({ - required this.provider, - required this.options, - required this.coords, - required this.startedLoading, - required this.finishedLoadingBytes, - }); - - /// An instance of the [FMTCTileProvider] in use - final FMTCTileProvider provider; - - /// An instance of the [TileLayer] in use - final TileLayer options; - - /// The coordinates of the tile to be fetched - final TileCoordinates coords; - - /// 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. - final void Function() startedLoading; - - /// Function invoked when the image completes loading bytes from the network - /// - /// Used with [finishedLoadingBytes] to safely dispose of the `httpClient` only - /// after all tiles have loaded. - final void Function() finishedLoadingBytes; - - @override - ImageStreamCompleter loadImage( - FMTCImageProvider key, - ImageDecoderCallback decode, - ) { - final chunkEvents = StreamController(); - return MultiFrameImageStreamCompleter( - codec: _loadAsync(key, chunkEvents, decode), - chunkEvents: chunkEvents.stream, - scale: 1, - debugLabel: coords.toString(), - informationCollector: () => [ - DiagnosticsProperty('Store name', provider.storeName), - DiagnosticsProperty('Tile coordinates', coords), - DiagnosticsProperty('Current provider', key), - ], - ); - } - - Future _loadAsync( - FMTCImageProvider key, - StreamController chunkEvents, - ImageDecoderCallback decode, - ) async { - Future finishWithError(FMTCBrowsingError err) async { - scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); - unawaited(chunkEvents.close()); - finishedLoadingBytes(); - - provider.settings.errorHandler?.call(err); - throw err; - } - - Future finishSuccessfully({ - required Uint8List bytes, - required bool cacheHit, - }) async { - scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); - unawaited(chunkEvents.close()); - finishedLoadingBytes(); - - unawaited( - FMTCBackendAccess.internal - .registerHitOrMiss(storeName: provider.storeName, hit: cacheHit), - ); - return decode(await ImmutableBuffer.fromUint8List(bytes)); - } - - 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, - ); - } - return null; - } - - startedLoading(); - - final networkUrl = provider.getTileUrl(coords, options); - final matcherUrl = obscureQueryParams( - url: networkUrl, - obscuredQueryParams: provider.settings.obscuredQueryParams, - ); - - final existingTile = await FMTCBackendAccess.internal.readTile( - url: matcherUrl, - storeName: provider.storeName, - ); - - final needsCreating = existingTile == null; - final needsUpdating = !needsCreating && - (provider.settings.behavior == CacheBehavior.onlineFirst || - (provider.settings.cachedValidDuration != Duration.zero && - DateTime.timestamp().millisecondsSinceEpoch - - existingTile.lastModified.millisecondsSinceEpoch > - provider.settings.cachedValidDuration.inMilliseconds)); - - // Prepare a list of image bytes and prefill if there's already a cached - // tile available - Uint8List? bytes; - if (!needsCreating) bytes = existingTile.bytes; - - // If there is a cached tile that's in date available, use it - if (!needsCreating && !needsUpdating) { - return finishSuccessfully(bytes: bytes!, cacheHit: true); - } - - // 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; - - return finishWithError( - 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)) - ..headers.addAll(provider.headers); - final StreamedResponse response; - try { - response = await provider.httpClient.send(request); - } catch (e) { - if (!needsCreating) { - return finishSuccessfully(bytes: bytes!, cacheHit: false); - } - - 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, - ), - ); - } - - // Check whether the network response is not 200 OK - if (response.statusCode != 200) { - if (!needsCreating) { - return finishSuccessfully(bytes: bytes!, cacheHit: false); - } - - final codec = await attemptFinishViaAltStore(matcherUrl); - if (codec != null) return codec; - - return finishWithError( - 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 - 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) { - return finishSuccessfully(bytes: bytes!, cacheHit: false); - } - - final codec = await attemptFinishViaAltStore(matcherUrl); - if (codec != null) return codec; - - return finishWithError( - FMTCBrowsingError( - type: FMTCBrowsingErrorType.invalidImageData, - networkUrl: networkUrl, - matcherUrl: matcherUrl, - request: request, - response: response, - ), - ); - } - - // 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) { - unawaited( - FMTCBackendAccess.internal.removeOldestTilesAboveLimit( - storeName: provider.storeName, - tilesLimit: provider.settings.maxStoreLength, - ), - ); - } - - return finishSuccessfully(bytes: responseBytes, cacheHit: false); - } - - @override - Future obtainKey(ImageConfiguration configuration) => - SynchronousFuture(this); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is FMTCImageProvider && - other.runtimeType == runtimeType && - other.coords == coords && - other.provider == provider && - other.options == options); - - @override - int get hashCode => Object.hash(coords, provider, options); -} diff --git a/lib/src/providers/browsing_errors.dart b/lib/src/providers/image_provider/browsing_errors.dart similarity index 82% rename from lib/src/providers/browsing_errors.dart rename to lib/src/providers/image_provider/browsing_errors.dart index cb0af4f0..d91935b0 100644 --- a/lib/src/providers/browsing_errors.dart +++ b/lib/src/providers/image_provider/browsing_errors.dart @@ -3,17 +3,16 @@ 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'; +import '../../../flutter_map_tile_caching.dart'; /// An [Exception] indicating that there was an error retrieving tiles to be /// displayed on the map /// /// 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 +23,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 @@ -33,8 +32,7 @@ class FMTCBrowsingError implements Exception { FMTCBrowsingError({ required this.type, required this.networkUrl, - required this.matcherUrl, - this.request, + required this.storageSuitableUID, this.response, this.originalError, }) : message = '${type.explanation} ${type.resolution}'; @@ -52,32 +50,27 @@ 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; - - /// If available, the attempted HTTP request - /// - /// Will be available if [type] is not - /// [FMTCBrowsingErrorType.missingInCacheOnlyMode]. - final Request? request; + /// The storage-suitable UID of the tile: the result of + /// [FMTCTileProvider.urlTransformer] on [networkUrl] + final String storageSuitableUID; /// 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 /// /// Will be available if [type] is - /// [FMTCBrowsingErrorType.noConnectionDuringFetch] or - /// [FMTCBrowsingErrorType.unknownFetchException]. + /// [FMTCBrowsingErrorType.noConnectionDuringFetch], + /// [FMTCBrowsingErrorType.unknownFetchException], or + /// [FMTCBrowsingErrorType.invalidImageData]. final Object? originalError; @override @@ -92,10 +85,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 @@ -113,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 ' @@ -129,16 +119,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 new file mode 100644 index 00000000..bdad0f15 --- /dev/null +++ b/lib/src/providers/image_provider/image_provider.dart @@ -0,0 +1,158 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +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 + const _FMTCImageProvider({ + required this.provider, + required this.options, + required this.coords, + required this.startedLoading, + required this.finishedLoadingBytes, + }); + + /// An instance of the [FMTCTileProvider] in use + final FMTCTileProvider provider; + + /// An instance of the [TileLayer] in use + final TileLayer options; + + /// The coordinates of the tile to be fetched + final TileCoordinates coords; + + /// 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. + final void Function() startedLoading; + + /// Function invoked when the image completes loading bytes from the network + /// + /// Used with [startedLoading] to safely dispose of the `httpClient` only + /// after all tiles have loaded. + final void Function() finishedLoadingBytes; + + @override + ImageStreamCompleter loadImage( + _FMTCImageProvider key, + ImageDecoderCallback decode, + ) => + MultiFrameImageStreamCompleter( + codec: provideTile( + 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: () { + final tileUrl = provider.getTileUrl(coords, options); + + return [ + DiagnosticsProperty('Stores', provider.stores), + DiagnosticsProperty('Tile coordinates', coords), + DiagnosticsProperty('Tile URL', tileUrl), + DiagnosticsProperty( + 'Tile storage-suitable UID', + provider.urlTransformer?.call(tileUrl) ?? tileUrl, + ), + ]; + }, + ); + + /// {@macro fmtc.tileProvider.provideTile} + static Future provideTile({ + required TileCoordinates coords, + required TileLayer options, + required FMTCTileProvider provider, + Object? key, + void Function()? startedLoading, + void Function()? finishedLoadingBytes, + bool requireValidImage = false, + }) async { + startedLoading?.call(); + + final currentTLIR = + provider.tileLoadingInterceptor != null ? _TLIRConstructor._() : null; + + void close([({Object error, StackTrace stackTrace})? error]) { + finishedLoadingBytes?.call(); + + if (key != null && error != null) { + scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); + } + + if (currentTLIR != null) { + currentTLIR.error = error; + + provider.tileLoadingInterceptor! + ..value[coords] = TileLoadingInterceptorResult._( + resultPath: currentTLIR.resultPath, + error: currentTLIR.error, + networkUrl: currentTLIR.networkUrl, + storageSuitableUID: currentTLIR.storageSuitableUID, + existingStores: currentTLIR.existingStores, + tileRetrievedFromOtherStoresAsFallback: + currentTLIR.tileRetrievableFromOtherStoresAsFallback && + currentTLIR.resultPath == + TileLoadingInterceptorResultPath.cacheAsFallback, + needsUpdating: currentTLIR.needsUpdating, + hitOrMiss: currentTLIR.hitOrMiss, + storesWriteResult: currentTLIR.storesWriteResult, + 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(); + } + } + + final Uint8List bytes; + try { + bytes = await _internalTileBrowser( + coords: coords, + options: options, + provider: provider, + requireValidImage: requireValidImage, + currentTLIR: currentTLIR, + ); + } catch (err, stackTrace) { + close((error: err, stackTrace: stackTrace)); + + if (err is FMTCBrowsingError) { + final handlerResult = provider.errorHandler?.call(err); + if (handlerResult != null) return handlerResult; + } + + rethrow; + } + + close(); + return bytes; + } + + @override + Future<_FMTCImageProvider> obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is _FMTCImageProvider && + other.coords == coords && + other.provider == provider); + + @override + int get hashCode => Object.hash(coords, provider); +} diff --git a/lib/src/providers/image_provider/internal_tile_browser.dart b/lib/src/providers/image_provider/internal_tile_browser.dart new file mode 100644 index 00000000..393242d2 --- /dev/null +++ b/lib/src/providers/image_provider/internal_tile_browser.dart @@ -0,0 +1,266 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +Future _internalTileBrowser({ + required TileCoordinates coords, + required TileLayer options, + required FMTCTileProvider provider, + required bool requireValidImage, + required _TLIRConstructor? currentTLIR, +}) async { + late final compiledReadableStores = provider._compileReadableStores(); + + void registerHit(List storeNames) { + currentTLIR?.hitOrMiss = true; + if (provider.recordHitsAndMisses) { + FMTCBackendAccess.internal.incrementStoreHits(storeNames: storeNames); + } + } + + void registerMiss() { + currentTLIR?.hitOrMiss = false; + if (provider.recordHitsAndMisses) { + FMTCBackendAccess.internal + .incrementStoreMisses(storeNames: compiledReadableStores); + } + } + + final networkUrl = provider.getTileUrl(coords, options); + final matcherUrl = provider.urlTransformer?.call(networkUrl) ?? networkUrl; + + currentTLIR?.networkUrl = networkUrl; + currentTLIR?.storageSuitableUID = matcherUrl; + + late final DateTime cacheFetchStartTime; + if (currentTLIR != null) cacheFetchStartTime = DateTime.now(); + + final ( + tile: existingTile, + intersectedStoreNames: intersectedExistingStores, + allStoreNames: allExistingStores, + ) = await FMTCBackendAccess.internal.readTile( + url: matcherUrl, + storeNames: compiledReadableStores, + ); + + currentTLIR?.cacheFetchDuration = + DateTime.now().difference(cacheFetchStartTime); + + if (allExistingStores.isNotEmpty) { + currentTLIR?.existingStores = allExistingStores; + } + + final tileRetrievableFromOtherStoresAsFallback = existingTile != null && + provider.useOtherStoresAsFallbackOnly && + provider.stores.keys + .toSet() + .intersection(allExistingStores.toSet()) + .isEmpty; + + currentTLIR?.tileRetrievableFromOtherStoresAsFallback = + tileRetrievableFromOtherStoresAsFallback; + + // 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.loadingStrategy == BrowseLoadingStrategy.onlineFirst || + (provider.cachedValidDuration != Duration.zero && + DateTime.timestamp().millisecondsSinceEpoch - + existingTile.lastModified.millisecondsSinceEpoch > + provider.cachedValidDuration.inMilliseconds)); + + currentTLIR?.needsUpdating = needsUpdating; + + if (existingTile != null && + !needsUpdating && + !tileRetrievableFromOtherStoresAsFallback) { + currentTLIR?.resultPath = + TileLoadingInterceptorResultPath.perfectFromStores; + + 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.loadingStrategy == BrowseLoadingStrategy.cacheOnly) { + if (existingTile != null) { + currentTLIR?.resultPath = + TileLoadingInterceptorResultPath.cacheOnlyFromOtherStores; + + registerMiss(); + return bytes!; + } + + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.missingInCacheOnlyMode, + networkUrl: networkUrl, + storageSuitableUID: matcherUrl, + ); + } + + // Setup a network request for the tile & handle network exceptions + 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` flag set', + ); + } + response = await provider.httpClient + .get(Uri.parse(networkUrl), headers: provider.headers); + } catch (e) { + if (existingTile != null) { + currentTLIR?.resultPath = + TileLoadingInterceptorResultPath.cacheAsFallback; + + registerMiss(); + return bytes!; + } + + throw FMTCBrowsingError( + type: e is SocketException + ? FMTCBrowsingErrorType.noConnectionDuringFetch + : FMTCBrowsingErrorType.unknownFetchException, + networkUrl: networkUrl, + storageSuitableUID: matcherUrl, + 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.cacheAsFallback; + + registerMiss(); + return bytes!; + } + + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.negativeFetchResponse, + networkUrl: networkUrl, + storageSuitableUID: matcherUrl, + response: response, + ); + } + + // 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( + response.bodyBytes, + targetWidth: 8, + targetHeight: 8, + )) + .getNextFrame()) + .image + .width > + 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; + } + + if (isValidImageData != null) { + if (existingTile != null) { + currentTLIR?.resultPath = + TileLoadingInterceptorResultPath.cacheAsFallback; + + registerMiss(); + return bytes!; + } + + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.invalidImageData, + networkUrl: networkUrl, + storageSuitableUID: matcherUrl, + 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.stores.entries + .where( + (e) => switch (e.value) { + null => false, + BrowseStoreStrategy.read => false, + BrowseStoreStrategy.readUpdate => + intersectedExistingStores.contains(e.key), + BrowseStoreStrategy.readUpdateCreate => true, + }, + ) + .map((e) => e.key); + + final writeTileToIntermediate = + (provider.otherStoresStrategy == BrowseStoreStrategy.readUpdate && + existingTile != null + ? writeTileToSpecified.followedBy( + intersectedExistingStores + .whereNot((e) => provider.stores.containsKey(e)), + ) + : writeTileToSpecified) + .toSet() + .toList(growable: false); + + // Cache tile to necessary stores + if (writeTileToIntermediate.isNotEmpty || + provider.otherStoresStrategy == BrowseStoreStrategy.readUpdateCreate) { + final writeOp = FMTCBackendAccess.internal.writeTile( + storeNames: writeTileToIntermediate, + writeAllNotIn: + provider.otherStoresStrategy == BrowseStoreStrategy.readUpdateCreate + ? provider.stores.keys.toList(growable: false) + : null, + url: matcherUrl, + bytes: response.bodyBytes, + ); + currentTLIR?.storesWriteResult = writeOp; + + unawaited( + writeOp.then((result) { + 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; + + // Internally debounced, so we don't need to debounce here + FMTCBackendAccess.internal.removeOldestTilesAboveLimit( + storeNames: createdIn.toList(growable: false), + ); + }), + ); + } + + currentTLIR?.resultPath = TileLoadingInterceptorResultPath.fetchedFromNetwork; + + registerMiss(); + return response.bodyBytes; +} 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_interceptor/result.dart b/lib/src/providers/tile_loading_interceptor/result.dart new file mode 100644 index 00000000..a7b98836 --- /dev/null +++ b/lib/src/providers/tile_loading_interceptor/result.dart @@ -0,0 +1,140 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// A 'temporary' object that collects information from [_internalTileBrowser] +/// to be used to construct a [TileLoadingInterceptorResult] +/// +/// See documentation on [TileLoadingInterceptorResult] for more information +class _TLIRConstructor { + _TLIRConstructor._(); + + TileLoadingInterceptorResultPath? resultPath; + ({Object error, StackTrace stackTrace})? error; + late String networkUrl; + late String storageSuitableUID; + List? existingStores; + late bool tileRetrievableFromOtherStoresAsFallback; + 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 browsed tile load +@immutable +class TileLoadingInterceptorResult { + const TileLoadingInterceptorResult._({ + required this.resultPath, + required this.error, + required this.networkUrl, + required this.storageSuitableUID, + required this.existingStores, + required this.tileRetrievedFromOtherStoresAsFallback, + required this.needsUpdating, + required this.hitOrMiss, + required this.storesWriteResult, + required this.cacheFetchDuration, + required this.networkFetchDuration, + }); + + /// Indicates whether & how the tile completed loading successfully + /// + /// If `null`, loading was unsuccessful. Otherwise, the + /// [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. + final TileLoadingInterceptorResultPath? resultPath; + + /// 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 [resultPath] for + /// the exact result path. + final ({Object error, StackTrace stackTrace})? error; + + /// Indicates whether the tile completed loading successfully + /// + /// * `true`: completed - see [resultPath] for exact result path + /// * `false`: errored - see [error] for error/exception object + bool get didComplete => resultPath != null; + + /// The requested URL of the tile (based on the [TileLayer.urlTemplate]) + final String networkUrl; + + /// The storage-suitable UID of the tile: the result of + /// [FMTCTileProvider.urlTransformer] on [networkUrl] + final String storageSuitableUID; + + /// If the tile already existed, the stores that it existed in/belonged to + final List? existingStores; + + /// 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: + /// + /// ```txt + /// `useOtherStoresAsFallbackOnly` && + /// `resultPath` == TileLoadingInterceptorResultPath.cacheAsFallback && + /// && + /// + /// ``` + final bool tileRetrievedFromOtherStoresAsFallback; + + /// Whether the tile was indicated for updating (excluding creating) + /// + /// Calculated with: + /// + /// ```txt + /// && + /// ( + /// `loadingStrategy` == BrowseLoadingStrategy.onlineFirst || + /// + /// ) + /// ``` + final bool needsUpdating; + + /// Whether a hit or miss was (or would have) been recorded + /// + /// `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) + /// + /// Is a future because the result must come from an asynchronously triggered + /// database write operation. + /// + /// `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 + /// + /// 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 + /// 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_loading_interceptor/result_path.dart b/lib/src/providers/tile_loading_interceptor/result_path.dart new file mode 100644 index 00000000..91ccd768 --- /dev/null +++ b/lib/src/providers/tile_loading_interceptor/result_path.dart @@ -0,0 +1,25 @@ +// 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 TileLoadingInterceptorResultPath { + /// The tile was retrieved from: + /// + /// * the specified stores + /// * the unspecified stores, if + /// [FMTCTileProvider.useOtherStoresAsFallbackOnly] is `false` + perfectFromStores, + + /// 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 + cacheAsFallback, + + /// The tile was newly fetched from the network + fetchedFromNetwork, +} diff --git a/lib/src/providers/tile_provider.dart b/lib/src/providers/tile_provider.dart deleted file mode 100644 index c25557fa..00000000 --- a/lib/src/providers/tile_provider.dart +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of '../../flutter_map_tile_caching.dart'; - -/// Specialised [TileProvider] that uses a specialised [ImageProvider] to connect -/// to FMTC internals -/// -/// 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, - FMTCTileProviderSettings? settings, - Map? headers, - http.Client? httpClient, - ) : settings = settings ?? FMTCTileProviderSettings.instance, - httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), - super( - headers: (headers?.containsKey('User-Agent') ?? false) - ? headers - : _CustomUserAgentCompatMap(headers ?? {}), - ); - - /// The store name of the [FMTCStore] used when generating this provider - final String storeName; - - /// The tile provider settings to use - /// - /// Defaults to the ambient [FMTCTileProviderSettings.instance]. - final FMTCTileProviderSettings settings; - - /// [http.Client] (such as a [IOClient]) used to make all network requests - /// - /// 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 - /// - /// Used to avoid disposing of [httpClient] whilst HTTP requests are still - /// underway. - /// - /// Does not include tiles loaded from session cache. - final _tilesInProgress = HashMap>(); - - @override - ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => - FMTCImageProvider( - provider: this, - options: options, - coords: coordinates, - startedLoading: () => _tilesInProgress[coordinates] = Completer(), - finishedLoadingBytes: () { - _tilesInProgress[coordinates]?.complete(); - _tilesInProgress.remove(coordinates); - }, - ); - - @override - Future dispose() async { - if (_tilesInProgress.isNotEmpty) { - await Future.wait(_tilesInProgress.values.map((c) => c.future)); - } - httpClient.close(); - super.dispose(); - } - - /// 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, - required TileLayer options, - }) => - FMTCBackendAccess.internal.tileExistsInStore( - storeName: storeName, - url: obscureQueryParams( - url: getTileUrl(coords, options), - obscuredQueryParams: settings.obscuredQueryParams, - ), - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is FMTCTileProvider && - other.storeName == storeName && - other.headers == headers && - other.settings == settings && - other.httpClient == httpClient); - - @override - int get hashCode => Object.hash(storeName, 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 '); - } -} 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..454d3a50 --- /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. + @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/strategies.dart b/lib/src/providers/tile_provider/strategies.dart new file mode 100644 index 00000000..7dc2a538 --- /dev/null +++ b/lib/src/providers/tile_provider/strategies.dart @@ -0,0 +1,70 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// Alias for [BrowseLoadingStrategy], to ease migration from v9 -> v10 +@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, +/// and how to fallback +/// +/// | `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 + /// + /// In this mode, [BrowseStoreStrategy] is irrelevant. + /// + /// Throws [FMTCBrowsingErrorType.missingInCacheOnlyMode] if a tile is + /// unavailable. + /// + /// 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 + /// [BrowseStoreStrategy] + /// + /// 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 + /// [BrowseStoreStrategy] + /// + /// See documentation on [BrowseLoadingStrategy] for a strategy comparison + /// table. + onlineFirst, +} + +/// Determines when tiles should be written to a store during browse caching +enum BrowseStoreStrategy { + /// 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/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart new file mode 100644 index 00000000..a183fffb --- /dev/null +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -0,0 +1,457 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +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 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. +/// +/// {@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 { + /// 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, + this.urlTransformer, + this.errorHandler, + this.tileLoadingInterceptor, + Client? httpClient, + @visibleForTesting this.fakeNetworkDisconnect = false, + Map? headers, + }) : _wasClientAutomaticallyGenerated = httpClient == null, + httpClient = httpClient ?? IOClient(HttpClient()..userAgent = null), + super( + headers: (headers?.containsKey('User-Agent') ?? false) + ? headers + : _CustomUserAgentCompatMap(headers ?? {}), + ); + + /// 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.recordHitsAndMisses = true, + this.cachedValidDuration = Duration.zero, + this.urlTransformer, + this.errorHandler, + this.tileLoadingInterceptor, + Client? httpClient, + @visibleForTesting this.fakeNetworkDisconnect = false, + Map? headers, + }) : stores = const {}, + otherStoresStrategy = allStoresStrategy, + useOtherStoresAsFallbackOnly = false, + _wasClientAutomaticallyGenerated = httpClient == null, + 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 [BrowseStoreStrategy] + /// represents how that store should be used. + /// + /// Stores not included will not be used by default. However, + /// [otherStoresStrategy] determines whether & how all other unspecified + /// 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 [stores] + /// + /// `null` means that all other stores will not be used. + /// + /// 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 stores as normal. + /// + /// 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 + /// caching, and how to fallback + /// + /// Defaults to [BrowseLoadingStrategy.cacheFirst]. + final BrowseLoadingStrategy loadingStrategy; + + /// Whether to only use tiles retrieved by + /// [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). + /// + /// 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; + + /// 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.stores], 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 + /// + /// For more information, check the + /// [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 + /// the URL not directly related to the tile image change. + /// + /// [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. + /// + /// 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 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 + /// // outside of the `build` method + /// 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]s to [TileLoadingInterceptorResult]s. + final ValueNotifier? tileLoadingInterceptor; + + /// [Client] (such as a [IOClient]) used to make all network requests + /// + /// 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; + + /// 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 + /// + /// Used to avoid disposing of [httpClient] whilst HTTP requests are still + /// underway. + /// + /// Does not include tiles loaded from session cache. + final _tilesInProgress = HashMap>(); + + final bool _wasClientAutomaticallyGenerated; + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => + _FMTCImageProvider( + provider: this, + options: options, + coords: coordinates, + startedLoading: () => _tilesInProgress[coordinates] = Completer(), + finishedLoadingBytes: () { + _tilesInProgress[coordinates]?.complete(); + _tilesInProgress.remove(coordinates); + }, + ); + + @override + Future dispose() async { + if (_wasClientAutomaticallyGenerated) { + if (_tilesInProgress.isNotEmpty) { + await Future.wait(_tilesInProgress.values.map((c) => c.future)); + } + httpClient.close(); + } + super.dispose(); + } + + /// {@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 + /// + /// > [!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. + /// + /// 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. + /// + /// --- + /// + /// [key] is used to control the [ImageCache], and should be set when in a + /// context where [ImageProvider.obtainKey] is available. + /// + /// [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 provideTile({ + required TileCoordinates coords, + required TileLayer options, + Object? key, + void Function()? startedLoading, + void Function()? finishedLoadingBytes, + bool requireValidImage = false, + }) => + _FMTCImageProvider.provideTile( + coords: coords, + options: options, + provider: this, + key: key, + startedLoading: startedLoading, + finishedLoadingBytes: finishedLoadingBytes, + requireValidImage: requireValidImage, + ); + + /// Check whether a specified tile is cached in any of the current stores + /// + /// 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, + }) { + final networkUrl = getTileUrl(coords, options); + return FMTCBackendAccess.internal.tileExists( + storeNames: _compileReadableStores(), + url: urlTransformer?.call(networkUrl) ?? networkUrl, + ); + } + + /// 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 '&'). + /// + /// 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. + /// + /// See [urlTransformer] for more information. + 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; + } + + /// 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) => + identical(this, other) || + (other is FMTCTileProvider && + 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 && + mapEquals(other.stores, stores) && + 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)), + ]); +} diff --git a/lib/src/providers/tile_provider/typedefs.dart b/lib/src/providers/tile_provider/typedefs.dart new file mode 100644 index 00000000..5b1d752d --- /dev/null +++ b/lib/src/providers/tile_provider/typedefs.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'; + +/// Callback type for [FMTCTileProvider.urlTransformer] & +/// [StoreDownload.startForeground] +typedef UrlTransformer = String Function(String); + +/// Callback type for [FMTCTileProvider.errorHandler] +typedef BrowsingExceptionHandler = Uint8List? Function( + FMTCBrowsingError exception, +); diff --git a/lib/src/providers/tile_provider_settings.dart b/lib/src/providers/tile_provider_settings.dart deleted file mode 100644 index e482a52a..00000000 --- a/lib/src/providers/tile_provider_settings.dart +++ /dev/null @@ -1,164 +0,0 @@ -// 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 = 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. -enum CacheBehavior { - /// Only get 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. - cacheOnly, - - /// Retrieve tiles from the cache, only using the network to update the cached - /// tile if it has expired - /// - /// 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. - cacheFirst, - - /// Get tiles from the network where possible, and update the cached tiles - /// - /// Falls back to using cached tiles if the network is unavailable. - /// - /// If [FMTCTileProviderSettings.fallbackToAlternativeStore] is enabled, cached - /// tiles may also be taken from other stores. - onlineFirst, -} - -/// 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, - bool fallbackToAlternativeStore = true, - Duration cachedValidDuration = const Duration(days: 16), - int maxStoreLength = 0, - List obscuredQueryParams = const [], - FMTCBrowsingErrorHandler? errorHandler, - bool setInstance = true, - }) { - final settings = FMTCTileProviderSettings._( - behavior: behavior, - fallbackToAlternativeStore: fallbackToAlternativeStore, - cachedValidDuration: cachedValidDuration, - maxStoreLength: maxStoreLength, - obscuredQueryParams: obscuredQueryParams.map((e) => RegExp('$e=[^&]*')), - errorHandler: errorHandler, - ); - - if (setInstance) _instance = settings; - return settings; - } - - FMTCTileProviderSettings._({ - required this.behavior, - required this.cachedValidDuration, - required this.fallbackToAlternativeStore, - required this.maxStoreLength, - required this.obscuredQueryParams, - 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 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. - /// - /// When tiles are retrieved from other stores, it is counted as a miss for the - /// specified store. - /// - /// 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. - /// - /// 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; - - /// 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 - /// - /// If using this property, it is recommended to set it globally on - /// initialisation with [FMTCTileProviderSettings], to ensure it gets applied - /// throughout. - /// - /// Used by [obscureQueryParams] to apply to a URL. - /// - /// See the [online documentation](https://fmtc.jaffaketchup.dev/usage/integration#obscuring-query-parameters) - /// for more information. - final Iterable obscuredQueryParams; - - /// 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; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is FMTCTileProviderSettings && - other.behavior == behavior && - other.cachedValidDuration == cachedValidDuration && - other.maxStoreLength == maxStoreLength && - other.errorHandler == errorHandler && - other.obscuredQueryParams == obscuredQueryParams); - - @override - int get hashCode => Object.hashAllUnordered([ - behavior, - cachedValidDuration, - maxStoreLength, - errorHandler, - obscuredQueryParams, - ]); -} diff --git a/lib/src/regions/base_region.dart b/lib/src/regions/base_region.dart index 88841e90..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,26 +15,61 @@ 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] 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. + @Deprecated( + '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, 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. + @Deprecated( + '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, + 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 @@ -55,32 +84,10 @@ 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 /// + /// May not be supported on all region implementations. + /// /// Returns a `Iterable` which can be used anywhere. Iterable toOutline(); diff --git a/lib/src/regions/circle.dart b/lib/src/regions/circle.dart deleted file mode 100644 index 4b6b8b47..00000000 --- a/lib/src/regions/circle.dart +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -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] - const CircleRegion(this.center, this.radius); - - /// Center coordinate - final LatLng center; - - /// Radius in kilometers - final double radius; - - @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, - ); - - /// **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()); - - final radius = this.radius * 1000; - - for (int angle = -180; angle <= 180; angle++) { - yield dist.offset(center, radius, angle); - } - } - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is CircleRegion && - other.center == center && - other.radius == radius); - - @override - int get hashCode => Object.hash(center, radius); -} diff --git a/lib/src/regions/custom_polygon.dart b/lib/src/regions/custom_polygon.dart deleted file mode 100644 index 319d24d3..00000000 --- a/lib/src/regions/custom_polygon.dart +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -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] - const CustomPolygonRegion(this.outline); - - /// The outline coordinates - final List outline; - - @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, - ); - - /// **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; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is CustomPolygonRegion && listEquals(outline, other.outline)); - - @override - int get hashCode => outline.hashCode; -} diff --git a/lib/src/regions/downloadable_region.dart b/lib/src/regions/downloadable_region.dart index 8ef5d3f0..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, { @@ -29,9 +30,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 @@ -63,8 +61,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, @@ -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 @@ -97,7 +122,7 @@ class DownloadableRegion { other.originalRegion == originalRegion && other.minZoom == minZoom && other.maxZoom == maxZoom && - other.options == options && + other.options == options && //! Will never be equal other.start == start && other.end == end && other.crs == crs); diff --git a/lib/src/regions/line.dart b/lib/src/regions/line.dart deleted file mode 100644 index e498ebe4..00000000 --- a/lib/src/regions/line.dart +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -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] - const LineRegion(this.line, this.radius); - - /// The center line defined by a list of coordinates - final List line; - - /// The offset of the outline from the [line] in all directions (in meters) - final double radius; - - /// Generate the list of rectangle segments formed from the locus of this line - /// - /// Use the optional [overlap] argument to set the behaviour of the joints - /// between segments: - /// - /// * -1: joined by closest corners (largest gap) - /// * 0 (default): joined by centers - /// * 1 (as downloaded): joined by further corners (largest overlap) - Iterable> toOutlines([int overlap = 0]) sync* { - if (overlap < -1 || overlap > 1) { - throw ArgumentError('`overlap` must be between -1 and 1 inclusive'); - } - - if (line.isEmpty) return; - - const dist = Distance(); - final rad = radius * math.pi / 4; - - for (int i = 0; i < line.length - 1; i++) { - final cp = line[i]; - final np = line[i + 1]; - - final bearing = dist.bearing(cp, np); - final clockwiseRotation = - (90 + bearing) > 360 ? 360 - (90 + bearing) : (90 + bearing); - final anticlockwiseRotation = - (bearing - 90) < 0 ? 360 + (bearing - 90) : (bearing - 90); - - final tr = dist.offset(cp, rad, clockwiseRotation); // Top right - final br = dist.offset(np, rad, clockwiseRotation); // Bottom right - final bl = dist.offset(np, rad, anticlockwiseRotation); // Bottom left - final tl = dist.offset(cp, rad, anticlockwiseRotation); // Top left - - if (overlap == 0) yield [tr, br, bl, tl]; - - final r = overlap == -1; - final os = i == 0; - final oe = i == line.length - 2; - - yield [ - if (os) tr else dist.offset(tr, r ? rad : -rad, bearing), - if (oe) br else dist.offset(br, r ? -rad : rad, bearing), - if (oe) bl else dist.offset(bl, r ? -rad : rad, bearing), - if (os) tl else dist.offset(tl, r ? rad : -rad, bearing), - ]; - } - } - - @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, - ); - - /// **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 - /// > line - /// > - /// > Use the optional [overlap] argument to set the behaviour of the joints - /// between segments: - /// > - /// > * -1: joined by closest corners (largest gap), - /// > * 0 (default): joined by centers - /// > * 1 (as downloaded): joined by further corners (most overlap) - @override - Iterable toOutline([int overlap = 1]) => - toOutlines(overlap).expand((x) => x); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is LineRegion && - other.radius == radius && - listEquals(line, other.line)); - - @override - int get hashCode => Object.hash(line, radius); -} diff --git a/lib/src/regions/recovered_region.dart b/lib/src/regions/recovered_region.dart index 357e6d58..dd62bba7 100644 --- a/lib/src/regions/recovered_region.dart +++ b/lib/src/regions/recovered_region.dart @@ -3,25 +3,18 @@ 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. +/// Only [id] is used to compare equality. /// -/// 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]. +/// 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, @@ -29,13 +22,12 @@ 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 + /// + /// Only this is used to compare equality. final int id; /// The store name originally associated with this download @@ -59,30 +51,26 @@ 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; - /// 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; + /// The [BaseRegion] which was recovered + final R region; - /// Convert this region into a [BaseRegion] + /// Cast [region] from [R] to [N] /// - /// 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!); - } + /// 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( @@ -90,7 +78,7 @@ class RecoveredRegion { Crs crs = const Epsg3857(), }) => DownloadableRegion._( - toRegion(), + region, minZoom: minZoom, maxZoom: maxZoom, options: options, @@ -98,4 +86,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/regions/rectangle.dart b/lib/src/regions/rectangle.dart deleted file mode 100644 index 957cff6f..00000000 --- a/lib/src/regions/rectangle.dart +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -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] -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] - const RectangleRegion(this.bounds); - - /// The coordinate bounds - final LatLngBounds bounds; - - @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, - ); - - /// **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]; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is RectangleRegion && other.bounds == bounds); - - @override - int get hashCode => bounds.hashCode; -} diff --git a/lib/src/regions/shapes/circle.dart b/lib/src/regions/shapes/circle.dart new file mode 100644 index 00000000..1af03a9b --- /dev/null +++ b/lib/src/regions/shapes/circle.dart @@ -0,0 +1,59 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// A geographically circular region based off a [center] coord and [radius] +class CircleRegion extends BaseRegion { + /// Create a geographically circular region based off a [center] coord and + /// [radius] + const CircleRegion(this.center, this.radius); + + /// Center coordinate + final LatLng center; + + /// Radius in kilometers + final double radius; + + @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, + ); + + @override + Iterable toOutline() sync* { + const dist = Distance(roundResult: false, calculator: Haversine()); + + 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); + } + } + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CircleRegion && + other.center == center && + other.radius == radius); + + @override + int get hashCode => Object.hash(center, radius); +} diff --git a/lib/src/regions/shapes/custom_polygon.dart b/lib/src/regions/shapes/custom_polygon.dart new file mode 100644 index 00000000..e863f214 --- /dev/null +++ b/lib/src/regions/shapes/custom_polygon.dart @@ -0,0 +1,44 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// A geographical region who's outline is defined by a list of coordinates +class CustomPolygonRegion extends BaseRegion { + /// Create a geographical region who's outline is defined by a list of + /// coordinates + const CustomPolygonRegion(this.outline); + + /// The outline coordinates + final List outline; + + @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, + ); + + @override + List toOutline() => outline; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CustomPolygonRegion && listEquals(outline, other.outline)); + + @override + int get hashCode => Object.hashAll(outline); +} diff --git a/lib/src/regions/shapes/line.dart b/lib/src/regions/shapes/line.dart new file mode 100644 index 00000000..b1ab80cf --- /dev/null +++ b/lib/src/regions/shapes/line.dart @@ -0,0 +1,109 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// A geographically line/locus region based off a list of coords and a [radius] +class LineRegion extends BaseRegion { + /// 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 + final List line; + + /// The offset of the outline from the [line] in all directions (in meters) + final double radius; + + /// Generate the list of rectangle segments formed from the locus of this line + /// + /// Use the optional [overlap] argument to set the behaviour of the joints + /// between segments: + /// + /// * -1: joined by closest corners (largest gap) + /// * 0 (default): joined by centers + /// * 1 (as downloaded): joined by further corners (largest overlap) + Iterable> toOutlines([int overlap = 0]) sync* { + if (overlap < -1 || overlap > 1) { + throw ArgumentError('`overlap` must be between -1 and 1 inclusive'); + } + + if (line.isEmpty) return; + + const dist = Distance(); + final rad = radius * pi / 4; + + for (int i = 0; i < line.length - 1; i++) { + final cp = line[i]; + final np = line[i + 1]; + + final bearing = dist.bearing(cp, np); + final clockwiseRotation = + (90 + bearing) > 360 ? 360 - (90 + bearing) : (90 + bearing); + final anticlockwiseRotation = + (bearing - 90) < 0 ? 360 + (bearing - 90) : (bearing - 90); + + final tr = dist.offset(cp, rad, clockwiseRotation); // Top right + final br = dist.offset(np, rad, clockwiseRotation); // Bottom right + final bl = dist.offset(np, rad, anticlockwiseRotation); // Bottom left + final tl = dist.offset(cp, rad, anticlockwiseRotation); // Top left + + if (overlap == 0) yield [tr, br, bl, tl]; + + final r = overlap == -1; + final os = i == 0; + final oe = i == line.length - 2; + + yield [ + if (os) tr else dist.offset(tr, r ? rad : -rad, bearing), + if (oe) br else dist.offset(br, r ? -rad : rad, bearing), + if (oe) bl else dist.offset(bl, r ? -rad : rad, bearing), + if (os) tl else dist.offset(tl, r ? rad : -rad, bearing), + ]; + } + } + + @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, + ); + + /// Flattens the result of [toOutlines] - its documentation is quoted below + /// + /// > Generate the list of rectangle segments formed from the locus of this + /// > line + /// > + /// > Use the optional [overlap] argument to set the behaviour of the joints + /// between segments: + /// > + /// > * -1: joined by closest corners (largest gap), + /// > * 0 (default): joined by centers + /// > * 1 (as downloaded): joined by further corners (most overlap) + @override + Iterable toOutline([int overlap = 1]) => + toOutlines(overlap).expand((x) => x); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LineRegion && + other.radius == radius && + listEquals(line, other.line)); + + @override + 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..6e475091 --- /dev/null +++ b/lib/src/regions/shapes/multi.dart @@ -0,0 +1,75 @@ +// 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. 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. +/// +/// [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 new file mode 100644 index 00000000..0574b99c --- /dev/null +++ b/lib/src/regions/shapes/rectangle.dart @@ -0,0 +1,47 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../../flutter_map_tile_caching.dart'; + +/// A geographically rectangular region based off coordinate bounds +/// +/// Does not support skewing into parallelograms: use [CustomPolygonRegion] +/// instead. +class RectangleRegion extends BaseRegion { + /// Create a geographically rectangular region based off coordinate bounds + const RectangleRegion(this.bounds); + + /// The coordinate bounds + final LatLngBounds bounds; + + @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, + ); + + @override + List toOutline() => + [bounds.northEast, bounds.southEast, bounds.southWest, bounds.northWest]; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RectangleRegion && other.bounds == bounds); + + @override + int get hashCode => bounds.hashCode; +} diff --git a/lib/src/root/external.dart b/lib/src/root/external.dart index 3765a7c8..bafb941e 100644 --- a/lib/src/root/external.dart +++ b/lib/src/root/external.dart @@ -26,41 +26,80 @@ 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. +/// --- /// -/// If the specified archive (at [pathToArchive]) is not of the expected format, -/// an error from the [ImportExportError] group will be thrown: +/// Archives are backend specific. They cannot be imported by a backend +/// different to the one that exported it. /// -/// - Doesn't exist (except [export]): [ImportPathNotExists] -/// - Not a file: [ImportExportPathNotFile] -/// - Not an FMTC archive: [ImportFileNotFMTCStandard] -/// - Not compatible with the current backend: [ImportFileNotBackendCompatible] +/// 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 /// 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), + /// > 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 /// their tiles /// - /// If a file already exists at [pathToArchive], it will be overwritten. - Future export({ + /// 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. + /// + /// Returns the number of exported tiles. + Future export({ required List storeNames, }) => FMTCBackendAccess.internal .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 +115,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); } diff --git a/lib/src/root/recovery.dart b/lib/src/root/recovery.dart index 62f19306..04f5a8ee 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] @@ -33,19 +31,26 @@ 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 { - RootRecovery._() { - _instance = this; - } + 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 = {}; + /// {@macro fmtc.backend.watchRecovery} + Stream watch({ + bool triggerImmediately = false, + }) => + FMTCBackendAccess.internal.watchRecovery( + triggerImmediately: triggerImmediately, + ); + /// 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/root.dart b/lib/src/root/root.dart index e01623d2..fa89b007 100644 --- a/lib/src/root/root.dart +++ b/lib/src/root/root.dart @@ -3,26 +3,11 @@ 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. /// -/// 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 { @@ -32,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 diff --git a/lib/src/root/statistics.dart b/lib/src/root/statistics.dart index fb668bb8..6b6055d9 100644 --- a/lib/src/root/statistics.dart +++ b/lib/src/root/statistics.dart @@ -8,29 +8,30 @@ 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} + @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, - }) async* { - final stream = FMTCBackendAccess.internal.watchRecovery( - triggerImmediately: triggerImmediately, - ); - yield* stream; - } + }) => + FMTCRoot.recovery.watch(triggerImmediately: triggerImmediately); /// {@macro fmtc.backend.watchStores} /// diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 52c2257b..80937529 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -7,14 +7,15 @@ part of '../../flutter_map_tile_caching.dart'; /// /// --- /// -/// {@template num_instances} -/// By default, only one download is allowed at any one time. +/// {@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' /// `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 @@ -33,13 +34,47 @@ class StoreDownload { /// Download a specified [DownloadableRegion] in the foreground, with a /// recovery session by default /// - /// > [!TIP] - /// > To check 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). + /// 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 + /// * 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) + /// * 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(%). + /// + /// The second stream (of [TileEvent]s) will emit events for every tile + /// download attempt. + /// + /// > [!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. /// /// --- /// @@ -63,9 +98,10 @@ 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] + /// > [!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. @@ -73,25 +109,26 @@ 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]. /// /// --- /// - /// 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} + /// 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). /// /// --- /// @@ -103,40 +140,58 @@ 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 info about [urlTransformer], see [FMTCTileProvider.urlTransformer] and + /// the + /// [online documentation](https://fmtc.jaffaketchup.dev/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]. /// /// --- /// - /// {@macro num_instances} - @useResult - Stream startForeground({ + /// {@macro fmtc.bulkDownload.numInstances} + ({ + 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, - List? obscuredQueryParams, + UrlTransformer? urlTransformer, Object instanceId = 0, - }) async* { - FMTCBackendAccess.internal; // Verify intialisation + }) { + FMTCBackendAccess.internal; // Verify initialisation - // Check input arguments for suitability + // Verify input arguments 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, @@ -144,7 +199,6 @@ class StoreDownload { 'must be 1 or greater', ); } - if (maxBufferLength < 0) { throw ArgumentError.value( maxBufferLength, @@ -152,7 +206,6 @@ class StoreDownload { 'must be 0 or greater', ); } - if ((rateLimit ?? 2) < 1) { throw ArgumentError.value( rateLimit, @@ -161,6 +214,16 @@ 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 { + resolvedUrlTransformer = (u) => u; + } + // Create download instance final instance = DownloadInstance.registerIfAvailable(instanceId); if (instance == null) { @@ -175,102 +238,165 @@ class StoreDownload { final recoveryId = disableRecovery ? null : 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(); - // 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, - obscuredQueryParams: - obscuredQueryParams?.map((e) => RegExp('$e=[^&]*')).toList() ?? - FMTCTileProviderSettings.instance.obscuredQueryParams.toList(), - recoveryId: recoveryId, - backend: FMTCBackendAccessThreadSafe.internal, - ), - onExit: receivePort.sendPort, - debugName: '[FMTC] Master Bulk Download Thread', + // Prepare output streams + // 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), ); - // Setup control mechanisms (completers) + // 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 below + return (pauseCompleter = Completer()).future; + } + ..requestResume = () { + sp.send(_DownloadManagerControlCmd.resume); + instance.isPaused = false; + }, + ); - await for (final evt in receivePort) { - // Handle new progress message - if (evt is DownloadProgress) { - yield evt; - continue; - } + () 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', + ); - // Handle pause comms - if (evt == 1) { - 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; + } - // Handle recovery system startup (unless disabled) - if (evt == 2) { - FMTCRoot.recovery._downloadsOngoing.add(recoveryId!); - 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(null); - return cancelCompleter.future; - } - ..requestPause = () { - evt.send(1); - return (pauseCompleter = Completer()).future - ..then((_) => instance.isPaused = true); - } - ..requestResume = () { - evt.send(2); - instance.isPaused = false; - }; - continue; + // Setup control mechanisms (senders) + if (evt is SendPort) { + sendPortCompleter.complete(evt); + continue; + } + + throw UnsupportedError('Unrecognised message: $evt'); } - 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 (recoveryId != null) await FMTCRoot.recovery.cancel(recoveryId); - DownloadInstance.unregister(instanceId); - cancelCompleter.complete(); + return ( + tileEvents: tileEventsStreamController.stream, + downloadProgress: downloadProgressStreamController.stream, + ); } - /// 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. - /// - /// Returns the number of tiles. - Future check(DownloadableRegion region) => compute( - region.when( - rectangle: (_) => TileCounters.rectangleTiles, - circle: (_) => TileCounters.circleTiles, - line: (_) => TileCounters.lineTiles, - customPolygon: (_) => TileCounters.customPolygonTiles, + /// 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]. + Future countTiles(DownloadableRegion region) => compute( + (region) => region.when( + rectangle: TileCounters.rectangleTiles, + circle: TileCounters.circleTiles, + line: TileCounters.lineTiles, + customPolygon: TileCounters.customPolygonTiles, + multi: TileCounters.multiTiles, ), 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( + '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 /// /// Will return once the cancellation is complete. Note that all running @@ -279,7 +405,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 => @@ -290,32 +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 num_instances} + /// 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). + /// + /// Any buffered tiles are not flushed. + /// + /// --- /// - /// 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(); + /// {@macro fmtc.bulkDownload.numInstances} + Future pause({Object instanceId = 0}) { + final instance = DownloadInstance.get(instanceId); + 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 num_instances} + /// 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}) => - DownloadInstance.get(instanceId)?.requestResume?.call(); + /// --- + /// + /// {@macro fmtc.bulkDownload.numInstances} + bool? resume({Object instanceId = 0}) { + final instance = DownloadInstance.get(instanceId); + 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 num_instances} - /// /// Also returns `false` if there is no ongoing download. + /// + /// --- + /// + /// {@macro fmtc.bulkDownload.numInstances} bool isPaused({Object instanceId = 0}) => DownloadInstance.get(instanceId)?.isPaused ?? false; } diff --git a/lib/src/store/manage.dart b/lib/src/store/manage.dart index e8edbacc..b9274cbe 100644 --- a/lib/src/store/manage.dart +++ b/lib/src/store/manage.dart @@ -17,9 +17,23 @@ class StoreManagement { Future get ready => FMTCBackendAccess.internal.storeExists(storeName: _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.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() => @@ -31,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, diff --git a/lib/src/store/statistics.dart b/lib/src/store/statistics.dart index f59119af..dfe6f234 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] @@ -16,10 +14,10 @@ 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. + /// {@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. /// {@endtemplate} Future<({double size, int length, int hits, int misses})> get all => FMTCBackendAccess.internal.getStoreStats(storeName: _storeName); @@ -27,22 +25,27 @@ 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 /// - /// {@macro fmtc.frontend.storestats.efficiency} + /// A hit is only counted when an unexpired tile is retrieved from the store. + /// + /// {@macro fmtc.storeStats.efficiency} Future get hits => all.then((a) => a.hits); /// Retrieve the number of unsuccessful tile retrievals when browsing /// - /// {@macro fmtc.frontend.storestats.efficiency} + /// 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.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 ea88e25f..6b89217c 100644 --- a/lib/src/store/store.dart +++ b/lib/src/store/store.dart @@ -3,21 +3,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 @@ -27,6 +12,7 @@ typedef StoreDirectory = FMTCStore; /// > 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); @@ -50,17 +36,45 @@ class FMTCStore { /// Provides bulk downloading functionality StoreDownload get download => StoreDownload._(storeName); - /// Generate a [TileProvider] that connects to FMTC internals + /// Generate an [FMTCTileProvider] that only specifies this store + /// + /// Prefer/migrate to the [FMTCTileProvider.new] constructor. /// - /// [settings] defaults to the current ambient - /// [FMTCTileProviderSettings.instance], which defaults to the initial - /// configuration if no other instance has been set. + /// {@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({ - FMTCTileProviderSettings? settings, + 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, + Client? httpClient, }) => - FMTCTileProvider._(storeName, settings, headers, httpClient); + FMTCTileProvider( + stores: {storeName: storeStrategy}, + otherStoresStrategy: otherStoresStrategy, + loadingStrategy: loadingStrategy, + useOtherStoresAsFallbackOnly: useOtherStoresAsFallbackOnly, + recordHitsAndMisses: recordHitsAndMisses, + cachedValidDuration: cachedValidDuration, + urlTransformer: urlTransformer, + errorHandler: errorHandler, + tileLoadingInterceptor: tileLoadingInterceptor, + headers: headers, + httpClient: httpClient, + ); @override bool operator ==(Object other) => diff --git a/pubspec.yaml b/pubspec.yaml index 9217c0c6..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: 9.1.4 +version: 10.0.0 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues @@ -33,19 +33,19 @@ dependencies: flat_buffers: ^23.5.26 flutter: sdk: flutter - flutter_map: ^7.0.0 - http: ^1.2.1 + 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.3 + objectbox_flutter_libs: ^4.0.3 path: ^1.9.0 path_provider: ^2.1.4 dev_dependencies: - build_runner: ^2.4.11 - objectbox_generator: ^4.0.1 - test: ^1.25.8 + build_runner: ^2.4.14 + objectbox_generator: ^4.0.3 + test: ^1.25.14 flutter: null diff --git a/test/general_test.dart b/test/general_test.dart index fd0bc64f..66595b14 100644 --- a/test/general_test.dart +++ b/test/general_test.dart @@ -311,7 +311,8 @@ void main() { 'Write tile (A64) to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], + writeAllNotIn: null, url: tileA64.url, bytes: tileA64.bytes, ); @@ -334,7 +335,8 @@ void main() { 'Write tile (A64) again to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], + writeAllNotIn: null, url: tileA64.url, bytes: tileA64.bytes, ); @@ -357,7 +359,8 @@ void main() { 'Write tile (A128) to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], + writeAllNotIn: null, url: tileA128.url, bytes: tileA128.bytes, ); @@ -380,7 +383,8 @@ void main() { 'Write tile (B64) to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], + writeAllNotIn: null, url: tileB64.url, bytes: tileB64.bytes, ); @@ -403,7 +407,8 @@ void main() { 'Write tile (B128) to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], + writeAllNotIn: null, url: tileB128.url, bytes: tileB128.bytes, ); @@ -426,7 +431,8 @@ void main() { 'Write tile (B64) again to "store1"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store1', + storeNames: ['store1'], + writeAllNotIn: null, url: tileB64.url, bytes: tileB64.bytes, ); @@ -471,7 +477,8 @@ void main() { 'Write tile (A64) to "store2"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store2', + storeNames: ['store2'], + writeAllNotIn: null, url: tileA64.url, bytes: tileA64.bytes, ); @@ -504,7 +511,8 @@ void main() { 'Write tile (A128) to "store2"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store2', + storeNames: ['store2'], + writeAllNotIn: null, url: tileA128.url, bytes: tileA128.bytes, ); @@ -564,7 +572,8 @@ void main() { 'Write tile (B64) to "store2"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store2', + storeNames: ['store2'], + writeAllNotIn: null, url: tileB64.url, bytes: tileB64.bytes, ); @@ -597,7 +606,8 @@ void main() { 'Write tile (A64) to "store2"', () async { await FMTCBackendAccess.internal.writeTile( - storeName: 'store2', + storeNames: ['store2'], + writeAllNotIn: null, url: tileA64.url, bytes: tileA64.bytes, ); @@ -627,17 +637,42 @@ 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'], + writeAllNotIn: null, + 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 +681,112 @@ 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'], + writeAllNotIn: null, + 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'], + writeAllNotIn: null, + 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, + ); }, ); diff --git a/test/region_tile_test.dart b/test/region_tile_test.dart index 3ec60989..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'; @@ -8,29 +9,42 @@ 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'; void main() { Future countByGenerator(DownloadableRegion region) async { - final tilereceivePort = ReceivePort(); + 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, + (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 +52,7 @@ void main() { } tileIsolate.kill(priority: Isolate.immediate); - tilereceivePort.close(); + tileReceivePort.close(); return evts; } @@ -81,6 +95,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)), ); @@ -190,19 +221,35 @@ void main() { test( 'Count By Counter', - () => expect(TileCounters.circleTiles(circleRegion), 61564), + () => expect(TileCounters.circleTiles(circleRegion), 115912), ); test( 'Count By Generator', - () async => expect(await countByGenerator(circleRegion), 61564), + () async => expect(await countByGenerator(circleRegion), 115912), + ); + + 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 +361,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 +390,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; - } -*/ 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'); diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index 476164ee..65baa309 100644 --- a/windowsApplicationInstallerSetup.iss +++ b/windowsApplicationInstallerSetup.iss @@ -1,12 +1,12 @@ ; Script generated by the Inno Setup Script Wizard #define MyAppName "FMTC Demo" -#define MyAppVersion "for 9.1.4" +#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" -#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,14 @@ 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\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]