diff --git a/.github/workflows/delete_old_runs.yml b/.github/workflows/delete_old_runs.yml deleted file mode 100644 index dddde2d9..00000000 --- a/.github/workflows/delete_old_runs.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Delete Old Workflow Runs -on: - schedule: - - cron: "0 0 * * 1" # Run every Monday - workflow_dispatch: - -jobs: - del_runs: - runs-on: ubuntu-latest - steps: - - uses: Mattraks/delete-workflow-runs@v2 - with: - token: ${{ github.token }} - repository: ${{ github.repository }} - retain_days: 7 - keep_minimum_runs: 0 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 08c068e6..e3ea1f2d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,89 +1,175 @@ -name: Analyse & Build -on: [push, workflow_dispatch] +name: CI/CD +on: + push: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: true jobs: - package-analysis: - name: "Analyse Package" - runs-on: ubuntu-latest - if: github.event.head_commit.message != 'Built Example Applications' - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - name: Run Dart Package Analyser - uses: axel-op/dart-package-analyzer@v3 - id: analysis - with: - githubToken: ${{ secrets.GITHUB_TOKEN }} - - name: Check Package Scores - env: - TOTAL: ${{ steps.analysis.outputs.total }} - TOTAL_MAX: ${{ steps.analysis.outputs.total_max }} - run: | - if (( $TOTAL < $TOTAL_MAX )) - then - echo Total score below expected minimum score. Improve the score! - exit 1 - fi + score-package: + name: "Score Package" + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@master + - name: Run Dart Package Analyser + uses: axel-op/dart-package-analyzer@master + id: analysis + with: + githubToken: ${{ secrets.GITHUB_TOKEN }} + - name: Check Package Scores + env: + TOTAL: ${{ steps.analysis.outputs.total }} + TOTAL_MAX: ${{ steps.analysis.outputs.total_max }} + run: | + if (( $TOTAL < $TOTAL_MAX )) + then + echo Package score less than available score. Improve the score! + exit 1 + fi + + analyse-code: + name: "Analyse Code" + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@master + - name: Setup Flutter Environment + uses: subosito/flutter-action@main + with: + channel: "beta" + - name: Get Package Dependencies + run: flutter pub get + - name: Get Example Dependencies + run: flutter pub get -C example + - name: Get Test Tile Server Dependencies + run: dart pub get -C tile_server + - name: Check Formatting + run: dart format --output=none --set-exit-if-changed . + - name: Check Lints + run: dart analyze --fatal-warnings + + run-tests: + name: "Run Tests" + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@master + - name: Setup Flutter Environment + uses: subosito/flutter-action@main + with: + channel: "beta" + - name: Get Dependencies + run: flutter pub get + - name: Install ObjectBox Libs For Testing + run: cd test && bash <(curl -s https://raw.githubusercontent.com/objectbox/objectbox-dart/main/install.sh) --quiet + - name: Run Tests + run: flutter test -r expanded - content-analysis: - name: "Analyse Contents" - runs-on: ubuntu-latest - if: github.event.head_commit.message != 'Built Example Applications' - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - name: Setup Flutter Environment - uses: subosito/flutter-action@v2 - with: - channel: "stable" - - name: Get All Dependencies - run: flutter pub get - - name: Check Formatting - run: dart format --output=none --set-exit-if-changed . - - name: Check Lints - run: dart analyze --fatal-infos --fatal-warnings + build-demo-android: + name: "Build Demo App (Android)" + runs-on: ubuntu-latest + needs: [analyse-code, run-tests] + defaults: + run: + working-directory: ./example + steps: + - name: Checkout Repository + uses: actions/checkout@master + - name: Setup Java 17 Environment + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "17" + - name: Setup Flutter Environment + uses: subosito/flutter-action@main + with: + channel: "beta" + - name: Build + run: flutter build apk --obfuscate --split-debug-info=./symbols + - name: Upload Artifact + uses: actions/upload-artifact@v3.1.2 + with: + name: android-demo + path: example/build/app/outputs/apk/release + if-no-files-found: error - build-example: - name: "Build Example Applications" - runs-on: windows-latest - needs: [content-analysis, package-analysis] - if: github.event.head_commit.message != 'Built Example Applications' - defaults: - run: - working-directory: ./example - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - name: Setup Java 17 Environment - uses: actions/setup-java@v3 - with: - distribution: "temurin" - java-version: "17" - - name: Setup Flutter Environment - uses: subosito/flutter-action@v2 - with: - channel: "stable" - - name: Remove Existing Prebuilt Applications - run: Remove-Item "prebuiltExampleApplications" -Recurse -ErrorAction Ignore - working-directory: . - - name: Create Prebuilt Applications (Output) Directory - run: md prebuiltExampleApplications - working-directory: . - - name: Get All Dependencies - run: flutter pub get - - name: Build Android Application - run: flutter build apk --obfuscate --split-debug-info=/symbols - - name: Move Android Application To Output Directory - run: move "example\build\app\outputs\flutter-apk\app-release.apk" "prebuiltExampleApplications\AndroidApplication.apk" - working-directory: . - - name: Build Windows Application - run: flutter build windows --obfuscate --split-debug-info=/symbols - - name: Create Windows Application Installer - run: iscc "windowsApplicationInstallerSetup.iss" - working-directory: . - - name: Commit Output Directory - uses: EndBug/add-and-commit@v9.0.1 - with: - message: "Built Example Applications" - add: "prebuiltExampleApplications/" - default_author: github_actions + build-demo-windows: + name: "Build Demo App (Windows)" + runs-on: windows-latest + needs: [analyse-code, run-tests] + defaults: + run: + working-directory: ./example + steps: + - name: Checkout Repository + uses: actions/checkout@master + - name: Setup Flutter Environment + uses: subosito/flutter-action@main + with: + channel: "beta" + - name: Build + run: flutter build windows --obfuscate --split-debug-info=./symbols + - name: Create Installer + run: iscc "windowsApplicationInstallerSetup.iss" + working-directory: . + - name: Upload Artifact + uses: actions/upload-artifact@v3.1.2 + with: + name: windows-demo + path: windowsTemp/WindowsApplication.exe + if-no-files-found: error + + build-tile-server-windows: + name: "Build Tile Server (Windows)" + runs-on: windows-latest + needs: [analyse-code, run-tests] + defaults: + run: + working-directory: ./tile_server + steps: + - name: Checkout Repository + uses: actions/checkout@master + - name: Setup Dart Environment + uses: dart-lang/setup-dart@v1.6.2 + - name: Get Dependencies + run: dart pub get + - name: Get Dart Dependencies + run: dart pub get + - name: Generate Tile Images + run: dart run bin/generate_dart_images.dart + - name: Compile + run: dart compile exe bin/tile_server.dart + - name: Upload Artifact + uses: actions/upload-artifact@v4.3.1 + with: + name: windows-ts + path: tile_server/bin/tile_server.exe + if-no-files-found: error + + build-tile-server-linux: + name: "Build Tile Server (Linux/Ubuntu)" + runs-on: ubuntu-latest + needs: [analyse-code, run-tests] + defaults: + run: + working-directory: ./tile_server + steps: + - name: Checkout Repository + uses: actions/checkout@master + - name: Setup Dart Environment + uses: dart-lang/setup-dart@v1.6.2 + - name: Get Dependencies + run: dart pub get + - name: Run Pre-Compile Generator + run: dart run bin/generate_dart_images.dart + - name: Compile + run: dart compile exe bin/tile_server.dart + - name: Upload Artifact + uses: actions/upload-artifact@v3.1.2 + with: + name: linux-ts + path: tile_server/bin/tile_server.exe + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 25dd6b1e..a4fb7a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Custom local/ -.fvm/ +test/lib/ # Miscellaneous *.class diff --git a/.pubignore b/.pubignore deleted file mode 100644 index e5ca5eba..00000000 --- a/.pubignore +++ /dev/null @@ -1 +0,0 @@ -prebuiltExampleApplications/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 2540a701..fba18866 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "version": "0.2.0", "configurations": [ { - "name": "Run Example App", + "name": "Run Demo App", "request": "launch", "type": "dart", "program": "example/lib/main.dart" diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 8699d5bc..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "cSpell.words": [ - "isar", - "lukas" - ] -} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8f2b877b..94d950d1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,21 +2,19 @@ "version": "2.0.0", "tasks": [ { - "type": "flutter", - "command": "flutter", + "type": "dart", + "command": "dart", "args": [ - "pub", "run", "build_runner", - "build", - "--delete-conflicting-outputs" + "build" ], "problemMatcher": [ "$dart-build_runner" ], "group": "build", "label": "Run Code Generator", - "detail": "flutter pub run build_runner build" + "detail": "dart run build_runner build" } ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff1d592..0d5e0233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,89 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons * @huulbaek * @andrewames * @ozzy1873 -* @mohammedX6 +* @eidolonFIRE * @weishuhn +* @mohammedX6 * and 3 anonymous or private donors # Changelog -## [8.0.1] - 2023/05/05 +## [9.0.0] - "Just Another Rewrite" - 2024/04/XX + +This update has essentially rewritten FMTC from the ground up, over hundreds of hours. It focuses on: + +* improving future maintainability by improving modularity and seperation of concerns +* improving stability & performance across the board +* supporting a many-to-many relationship between tiles and stores to reduce duplication + +I would hugely appricate any donations - please see the documentation site, GitHub repo, or pub.dev package. + +I would also like to thank all those who have been waiting and contributing their feedback throughout the process: it means a lot to me that FMTC is such a crucial component to your application. + +And without further ado, let's get the biggest changes out of the way first: + +* Added support for modular storage/root backends through `FMTCBackend` + * Removed Isar support + Isar unfortunately caused too many stability issues, and is not as actively maintained as I would like (I can sympathise :D). + * Added ObjectBox as the default backend (`FMTCObjectBoxBackend`) + ObjectBox uses the same underlying database technology as Isar (MBDX), but is more maintained, and I'm hoping, more stable. Note that ObjectBox declares it only supports 64-bit systems, whereas Isar was just 'mostly unstable' on 32-bit systems until recently (where is also became 64-bit only): it's time for the future! + * It is expected that backends support a many-to-many relationship between tiles and stores + This has reduced duplication between stores and tiles massively, and now allows for smaller, fine-grained region control. The default backend supports this with as minimal hit to performance as possible, although of course, database operations are now considerably more complex than in previous versions, and so therefore will take slightly longer. In practise, there is no noticeable performance difference. + * It is expected that backends cache statistics instead of calculating them at get time + This has decreased the time spent fetching basic statistics, and allowed for increased efficiency when getting multiple stats at once. Of course, there is some impact on performance at write time: it must all be accurately tracked, else it will be inaccacurate/out-of-sync. + +* Restructured top-level access APIs + * Deprecated `StoreDirectory` & `RootDirectory` in favour of `FMTCStore` and `FMTCRoot` + The term 'directory' has been misleading for a couple of years now, as it hasn't been actual filesystem directories storing information since the introduction of v7. + * Removed the `FlutterMapTileCaching`/`FMTC` access object, in favour of `FMTCStore` and `FMTCRoot` direct constructors + Much of the configuration and state management performed by this top-level object and it's close relatives were transferred to the backend, and as such, there is no longer a requirement for these objects. + * Removed support for synchronous operations (and renamed asynchronous operations to reflect this) + These were incompatible with the new `Isolate`d `FMTCObjectBoxBackend`, and to keep scope reasonable, I decided to remove them, in favour of backends implementing their own `Isolate`ion as well. + +* Reimplemented bulk downloading + * Added `CustomPolygonRegion`, a `BaseRegion` that is formed of any* outline + * Added pause and resume functionality + * Added rate limiting functionality + * Added support for multiple simultaneous downloads + * Improved developer experience by refactoring `DownloadableRegion` and `startForeground` + * Improved download speed significantly + * Fixed instability and bugs when cancelling buffering downloads + * Fixed generation of `LineRegion` tiles by reducing number of redundant duplicate tiles + * Fixed usage of `obscuredQueryParams` + * Removed support for bulk download buffering by size capacity + * Removed support for custom `HttpClient`s + +* Deprecated plugins + * Transfered support for import/export operations to core (`RootExternal`) + * Deprecated support for background bulk downloading + +* Migrated to Flutter 3.19 and Dart 3.3 +* Migrated to flutter_map v6 + +With those out of the way, we can take a look at the smaller changes: + +* Improved recovery system to monitor which tiles can be skipped on re-downloading (via `DownloadableRegion.start`) +* Improved error handling (especially in backends) +* Added `StoreManagement.pruneTilesOlderThan` method +* Added shortcut for getting multiple stats: `StoreStats.all` +* Added secondary check to `FMTCImageProvider` to ensure responses are valid images +* Replaced public facing `RegionType`/`type` with Dart 3 exhaustive switch statements through `BaseRegion/DownloadableRegion.when` & `RecoverableRegion.toRegion` +* Removed HTTP/2 support +* Fixed a whole bunch of bugs + +In addition, there's been more action in the surrounding enviroment: + +* Created a miniature testing tile server +* Created automated tests for tile generation +* Improved & simplified example application + * Removed update mechanism + * Added tile-by-tile/live download following + +## [8.0.1] - 2023/07/29 * Fixed bugs when generating tiles for `LineRegion` -## [8.0.0] - 2023/XX/XX +## [8.0.0] - 2023/05/05 * Bulk downloading has been rewritten to use a new implementation that generates tile coordinates at the same time as downloading tiles * `check`ing the number of tiles in a region now uses a significantly faster and more efficient implementation @@ -35,57 +107,60 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons * Added support for custom `HttpClient`s/`BaseClient`s * Added support for Isar v3.1 (bug fixes & stability improvements) -## [7.2.0] - 2023/03/03 - -* Stability improvements - * Starting multiple downloads no longer causes `LateInitializationErrors` - * Migrator storage and memory usage no longer spikes as significantly as previously, thanks to transaction batching - * Opening and processing of stores on initialisation is more robust and less error-prone to filename variations - * Root statistic watching now works on all platforms -* Multiple minor bug fixes and documentation improvements -* Added `maxStoreLength` config to example app - -## [7.1.2] - 2023/02/18 - -* Minor bug fixes - -## [7.1.1] - 2023/02/16 - -* Major bug fixes -* Added debug mode - -## [7.1.0] - 2023/02/14 - -* Added URL query params obscurer feature -* Added `headers` and `httpClient` parameters to `getTileProvider` -* Minor documentation improvements -* Minor bug fixes - -## [7.0.2] - 2023/02/12 - -* Minor changes to example application - -## [7.0.1] - 2023/02/11 - -* Minor bug fixes -* Minor improvements - -## [7.0.0] - 2023/02/04 - -* Migrated to Isar database -* Major performance improvements, thanks to Isar -* Added buffering to bulk tile downloading -* Added method to catch tile retrieval errors -* Removed v4 -> v5 migrator & added v6 -> v7 migrator -* Removed some synchronous methods from structure management -* Removed 'fmtc_advanced' import file - -Plus the usual: - -* Minor performance improvements -* Bug fixes -* Dependency updates -* Documentation improvements +> **Version 7 was made unstable due to a non-semantic versioning compliant update of a dependency.** +> **This means the pub version resolver can never resolve FMTC v7 without introducing compilation errors.** +> +> ## [7.2.0] - 2023/03/03 +> +> * Stability improvements +> * Starting multiple downloads no longer causes `LateInitializationErrors` +> * Migrator storage and memory usage no longer spikes as significantly as previously, thanks to transaction batching +> * Opening and processing of stores on initialisation is more robust and less error-prone to filename variations +> * Root statistic watching now works on all platforms +> * Multiple minor bug fixes and documentation improvements +> * Added `maxStoreLength` config to example app +> +> ## [7.1.2] - 2023/02/18 +> +> * Minor bug fixes +> +> ## [7.1.1] - 2023/02/16 +> +> * Major bug fixes +> * Added debug mode +> +> ## [7.1.0] - 2023/02/14 +> +> * Added URL query params obscurer feature +> * Added `headers` and `httpClient` parameters to `getTileProvider` +> * Minor documentation improvements +> * Minor bug fixes +> +> ## [7.0.2] - 2023/02/12 +> +> * Minor changes to example application +> +> ## [7.0.1] - 2023/02/11 +> +> * Minor bug fixes +> * Minor improvements +> +> ## [7.0.0] - 2023/02/04 +> +> * Migrated to Isar database +> * Major performance improvements, thanks to Isar +> * Added buffering to bulk tile downloading +> * Added method to catch tile retrieval errors +> * Removed v4 -> v5 migrator & added v6 -> v7 migrator +> * Removed some synchronous methods from structure management +> * Removed 'fmtc_advanced' import file +> +> Plus the usual: +> +> * Minor performance improvements +> * Bug fixes +> * Dependency updates +> * Documentation improvements ## [6.2.0] - 2022/10/25 diff --git a/README.md b/README.md index 2c017d74..db850e8d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ -# [flutter_map](https://pub.dev/packages/flutter_map)_tile_caching +# flutter_map_tile_caching -A plugin for ['flutter_map'](https://pub.dev/packages/flutter_map) providing advanced caching functionality, with ability to download map regions for offline use. Also includes useful prebuilt widgets. +A plugin for ['flutter_map'](https://pub.dev/packages/flutter_map) providing advanced offline functionality. -[![Pub](https://img.shields.io/pub/v/flutter_map_tile_caching.svg?label=Latest+Stable+Version)](https://pub.dev/packages/flutter_map_tile_caching) [![likes](https://img.shields.io/pub/likes/flutter_map_tile_caching?label=pub.dev+Likes)](https://pub.dev/packages/flutter_map_tile_caching/score) [![pub points](https://img.shields.io/pub/points/flutter_map_tile_caching?label=pub.dev+Points)](https://pub.dev/packages/flutter_map_tile_caching/score) -[![GitHub stars](https://img.shields.io/github/stars/JaffaKetchup/flutter_map_tile_caching.svg?label=GitHub+Stars)](https://GitHub.com/JaffaKetchup/flutter_map_tile_caching/stargazers/) [![GitHub issues](https://img.shields.io/github/issues/JaffaKetchup/flutter_map_tile_caching.svg?label=Issues)](https://GitHub.com/JaffaKetchup/flutter_map_tile_caching/issues/) [![GitHub PRs](https://img.shields.io/github/issues-pr/JaffaKetchup/flutter_map_tile_caching.svg?label=Pull%20Requests)](https://GitHub.com/JaffaKetchup/flutter_map_tile_caching/pulls/) +[![pub.dev](https://img.shields.io/pub/v/flutter_map_tile_caching.svg?label=Latest+Version)](https://pub.dev/packages/flutter_map_tile_caching) +[![stars](https://badgen.net/github/stars/JaffaKetchup/flutter_map_tile_caching?label=stars&color=green&icon=github)](https://github.com/JaffaKetchup/flutter_map_tile_caching/stargazers) +[![likes](https://img.shields.io/pub/likes/flutter_map_tile_caching?logo=flutter)](https://pub.dev/packages/flutter_map_tile_caching/score) +       +[![Open Issues](https://badgen.net/github/open-issues/JaffaKetchup/flutter_map_tile_caching?label=Open+Issues&color=green)](https://github.com/JaffaKetchup/flutter_map_tile_caching/issues) +[![Open PRs](https://badgen.net/github/open-prs/JaffaKetchup/flutter_map_tile_caching?label=Open+PRs&color=green)](https://github.com/JaffaKetchup/flutter_map_tile_caching/pulls) --- @@ -14,16 +18,22 @@ Alternatively, for the API reference, look at the [auto generated 'dartdoc'](htt --- -## Supporting Me +## [Supporting Me](https://github.com/sponsors/JaffaKetchup) -I work on all of my projects in my spare time, including maintaining (along with a team) Flutter's № 1 (non-commercially maintained) mapping library 'flutter_map', bringing it back from the brink of abandonment, as well as my own plugin for it ('flutter_map_tile_caching') that extends it with advanced caching and downloading. -Additionally, I also own the Dart encoder/decoder for the QOI image format ('dqoi') - and I am slowly working on 'flutter_osrm', a wrapper for the Open Source Routing Machine. +Hi there 👋 My name is Luka, but I go by JaffaKetchup! -Sponsorships & donations allow me to continue my projects and upgrade my hardware/setup, as well as allowing me to have some starting amount for further education and such-like. -And of course, a small amount will find its way into my Jaffa Cakes fund () - why do you think my username has "Jaffa" in it? -Many thanks for any amount you can spare, it means a lot to me! +I'm currently in full-time education in the UK studying Computer Science, Geography, and Mathematics, and have been building my software development skills alongside my education for many years. I specialise in the Flutter and Dart technologies to build cross-platform applications, and have over 4 years of experience both from a hobby and commercial standpoint. -You can read more about me and what I do on my [GitHub Sponsors](https://github.com/sponsors/JaffaKetchup) page, where you can donate as well. +I've worked as a senior developer with a small team at WatchEnterprise to develop their Flutter-based WatchCrunch social media app for Android & iOS. + +I'm one of a small team of maintainers for Flutter's №1 non-commercially aimed mapping library 'flutter_map', for which we make internal contributions, regulate and collaborate with external contributors, and offer support to a large community. I also personally develop a multitude of extension libraries, such as 'flutter_map_tile_caching'. In addition, I regularly contribute to OpenStreetMap, and continously improve my skills with personal experimental projects. + +I'm eager to earn other languages, and more than happy to talk about anything software development related! + +Sponsorships & donations allow me to further my education, whilst spening even more time developing the open-source projects that you use & love. I'm grateful for any amount you can spare, all support means a lot to me :) +If you can't support me financially, please consider leaving a star and a like on projects that worked well for you. + +I'm extremely greatful for any amount you can spare! [![Sponsor Me Via GitHub Sponsors](https://github.com/JaffaKetchup/flutter_map_tile_caching/blob/main/GitHubSponsorsImage.jpg)](https://github.com/sponsors/JaffaKetchup) @@ -35,10 +45,10 @@ This project is released under GPL v3. For detailed information about this licen > Permissions of this strong copyleft license are conditioned on making available complete source code of licensed works and modifications, which include larger works using a licensed work, under the same license. Copyright and license notices must be preserved. Contributors provide an express grant of patent rights. -Essentially, whilst you can use this code within commercial projects, they must not be proprietary - they incorporate this 'licensed work' so they must be available under the same license. You must distribute your source code on request (under the same GPL v3 license) to anyone who uses your program. +Essentially, whilst you can use this code within commercial projects, they must not be proprietary - they incorporate this 'licensed work' so they must be available under the same license. You must distribute your source code (at least on request) (under the same GPL v3 license) to anyone who uses your program. -However, I am willing to sell alternative (proprietary) licenses on a case-by-case basis and on request. +I learnt (and am still learning) to code with free, open-source software due to my age and lack of money, and for that reason, I believe in promoting open-source wherever possible to give equal opportunities to everybody, no matter their age, financial position, or any other characteristic. I'm not sure it's fair for commercial & proprietary applications to use software made by people for free out of generosity without giving back to the ecosystem or maintainer(s). -I learnt (and am still learning) to code with free, open-source software due to my age and lack of money, and for that reason, I believe in promoting open-source wherever possible to give equal opportunities to everybody, no matter their age or financial position. I'm not sure it's fair for commercial proprietary applications to use software made by people for free out of generosity. On the other hand, I am also trying to make a small amount of money from my projects, by donations or by selling licenses. And I recognise that commercial businesses may want to use my projects for their own proprietary applications. +On the other hand, I recognise that commercial businesses may want to use my projects for their own proprietary applications, and are happy to support me, and I am also trying to make a small amount of money from my projects, by donations and by selling licenses! -Therefore, if you would like a license to use this software within a proprietary, I am willing to sell a (preferably yearly or usage based) license for a reasonable price. If this seems like what you want/need, please do not hesitate to get in touch via [fmtc@jaffaketchup.dev](mailto:fmtc@jaffaketchup.dev). +Therefore, if you would like a license to use this software within a proprietary application, I am willing to sell a (preferably yearly) license. If this seems like what you'd be interested in, please do not hesitate to get in touch at [fmtc@jaffaketchup.dev](mailto:fmtc@jaffaketchup.dev). Please include details of your project if you can, and the approximate scale/audience for your app; I try to find something that works for everyone, and I'm happy to negotiate! If you're a non-profit organization, I'm happy to also offer an alternative license for free*! diff --git a/analysis_options.yaml b/analysis_options.yaml index d16bc85b..97dbfab7 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,8 +2,8 @@ include: jaffa_lints.yaml analyzer: exclude: - - lib/src/db/defs/*.g.dart + - lib/**.g.dart linter: rules: - avoid_slow_async_io: false + avoid_slow_async_io: false \ No newline at end of file diff --git a/dartdoc_options.yaml b/dartdoc_options.yaml deleted file mode 100644 index b795b9fe..00000000 --- a/dartdoc_options.yaml +++ /dev/null @@ -1,2 +0,0 @@ -dartdoc: - favicon: 'example/assets/icons/ProjectIcon.png' \ No newline at end of file diff --git a/example/.metadata b/example/.metadata index 6884bd8c..bead5eef 100644 --- a/example/.metadata +++ b/example/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: f72efea43c3013323d1b95cff571f3c1caa37583 - channel: stable + revision: "2524052335ec76bb03e04ede244b071f1b86d190" + channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: f72efea43c3013323d1b95cff571f3c1caa37583 - base_revision: f72efea43c3013323d1b95cff571f3c1caa37583 + create_revision: 2524052335ec76bb03e04ede244b071f1b86d190 + base_revision: 2524052335ec76bb03e04ede244b071f1b86d190 - platform: windows - create_revision: f72efea43c3013323d1b95cff571f3c1caa37583 - base_revision: f72efea43c3013323d1b95cff571f3c1caa37583 + create_revision: 2524052335ec76bb03e04ede244b071f1b86d190 + base_revision: 2524052335ec76bb03e04ede244b071f1b86d190 # User provided section diff --git a/example/README.md b/example/README.md index aa3e563f..9956a8fc 100644 --- a/example/README.md +++ b/example/README.md @@ -1,4 +1,4 @@ -# Example Application For '[flutter_map_tile_caching](https://github.com/JaffaKetchup/flutter_map_tile_caching)' +# Demonstration Application For '[flutter_map_tile_caching](https://github.com/JaffaKetchup/flutter_map_tile_caching)' Showcases functionality of the library in a neat and useful format that can be used for further API references, and just to see if you want this library for your app. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 00000000..cb1978b3 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,5 @@ +include: ../analysis_options.yaml + +linter: + rules: + public_member_api_docs: false diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index d7f28791..8db3c5c8 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -13,12 +13,12 @@ if (flutterRoot == null) { def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { - flutterVersionCode = '8' + flutterVersionCode = '9' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { - flutterVersionName = '8.0.0' + flutterVersionName = '9.0.0' } apply plugin: 'com.android.application' @@ -53,7 +53,7 @@ android { } defaultConfig { - applicationId "dev.org.fmtc.example.fmtc_example" + applicationId "dev.jaffaketchup.fmtc.demo" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode flutterVersionCode.toInteger() diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml index 4c97235c..2c45d295 100644 --- a/example/android/app/src/debug/AndroidManifest.xml +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -1,9 +1,6 @@ + package="dev.jaffaketchup.fmtc.demo"> - - - diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index c0ad88ee..804d6826 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,8 @@ + package="dev.jaffaketchup.fmtc.demo"> - - - + package="dev.jaffaketchup.fmtc.demo"> - - - diff --git a/example/android/build.gradle b/example/android/build.gradle index 96f87494..c62a13ac 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,6 +1,8 @@ buildscript { - ext.kotlin_version = '1.7.0' ext { + kotlin_version = '1.8.21' + gradle_version = '7.4.0' + compileSdkVersion = 33 targetSdkVersion = 29 minSdkVersion = 23 @@ -13,7 +15,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.2.0' + classpath "com.android.tools.build:gradle:$gradle_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -31,6 +33,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index cc5527d7..ceccc3a8 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip diff --git a/example/androidBuilder.bat b/example/androidBuilder.bat deleted file mode 100644 index eeaf794f..00000000 --- a/example/androidBuilder.bat +++ /dev/null @@ -1,16 +0,0 @@ -@ECHO OFF - -flutter clean | more -flutter build apk --split-per-abi --obfuscate --split-debug-info=/symbols | more -goto :choice - -:choice -set /P c=Install ('app-armeabi-v7a-release.apk') over active ADB connection [Y/N]? -if /I "%c%" EQU "Y" goto :install -if /I "%c%" EQU "N" EXIT /B -goto :choice - - -:install -adb install build\app\outputs\flutter-apk\app-armeabi-v7a-release.apk -EXIT /B \ No newline at end of file diff --git a/example/currentAppVersion.txt b/example/currentAppVersion.txt deleted file mode 100644 index fa5fce04..00000000 --- a/example/currentAppVersion.txt +++ /dev/null @@ -1 +0,0 @@ -8.0.0 \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index 2cd86983..2589a9f7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,15 +1,15 @@ -import 'dart:io'; - 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:path/path.dart' as p; 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 'shared/state/download_provider.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'; void main() async { @@ -21,69 +21,74 @@ void main() async { ), ); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - - String? damagedDatabaseDeleted; - await FlutterMapTileCaching.initialise( - errorHandler: (error) => damagedDatabaseDeleted = error.message, - debugMode: true, - ); - - await FMTC.instance.rootDirectory.migrator.fromV6(urlTemplates: []); - - if (prefs.getBool('reset') ?? false) { - await FMTC.instance.rootDirectory.manage.reset(); + Object? initErr; + try { + await FMTCObjectBoxBackend().initialise(); + } catch (err) { + initErr = err; } - final File newAppVersionFile = File( - p.join( - // ignore: invalid_use_of_internal_member, invalid_use_of_protected_member - FMTC.instance.rootDirectory.directory.absolute.path, - 'newAppVersion.${Platform.isWindows ? 'exe' : 'apk'}', - ), - ); - if (await newAppVersionFile.exists()) await newAppVersionFile.delete(); - - runApp(AppContainer(damagedDatabaseDeleted: damagedDatabaseDeleted)); + runApp(_AppContainer(initialisationError: initErr)); } -class AppContainer extends StatelessWidget { - const AppContainer({ - super.key, - required this.damagedDatabaseDeleted, +class _AppContainer extends StatelessWidget { + const _AppContainer({ + required this.initialisationError, }); - final String? damagedDatabaseDeleted; + final Object? initialisationError; @override - Widget build(BuildContext context) => MultiProvider( - providers: [ - ChangeNotifierProvider( - create: (context) => GeneralProvider(), - ), - ChangeNotifierProvider( - create: (context) => DownloadProvider(), + Widget build(BuildContext context) { + final themeData = ThemeData( + brightness: Brightness.dark, + useMaterial3: true, + textTheme: GoogleFonts.ubuntuTextTheme(ThemeData.dark().textTheme), + colorSchemeSeed: Colors.red, + switchTheme: SwitchThemeData( + thumbIcon: WidgetStateProperty.resolveWith( + (states) => Icon( + states.contains(WidgetState.selected) ? Icons.check : Icons.close, ), - ], - child: MaterialApp( - title: 'FMTC Example', - theme: ThemeData( - brightness: Brightness.dark, - useMaterial3: true, - textTheme: GoogleFonts.openSansTextTheme(const TextTheme()), - colorSchemeSeed: Colors.deepOrange, - switchTheme: SwitchThemeData( - thumbIcon: MaterialStateProperty.resolveWith( - (states) => Icon( - states.contains(MaterialState.selected) - ? Icons.check - : Icons.close, - ), - ), - ), - ), - debugShowCheckedModeBanner: false, - home: MainScreen(damagedDatabaseDeleted: damagedDatabaseDeleted), ), + ), + ); + + if (initialisationError case final err?) { + return MaterialApp( + title: 'FMTC Demo (Initialisation Error)', + theme: themeData, + home: InitialisationError(err: err), ); + } + + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => GeneralProvider(), + ), + ChangeNotifierProvider( + create: (_) => MapProvider(), + lazy: true, + ), + ChangeNotifierProvider( + create: (_) => RegionSelectionProvider(), + lazy: true, + ), + ChangeNotifierProvider( + create: (_) => ConfigureDownloadProvider(), + lazy: true, + ), + ChangeNotifierProvider( + create: (_) => DownloadingProvider(), + lazy: true, + ), + ], + child: MaterialApp( + title: 'FMTC Demo', + theme: themeData, + home: const MainScreen(), + ), + ); + } } diff --git a/example/lib/screens/configure_download/components/numerical_input_row.dart b/example/lib/screens/configure_download/components/numerical_input_row.dart new file mode 100644 index 00000000..a3fbf60d --- /dev/null +++ b/example/lib/screens/configure_download/components/numerical_input_row.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../state/configure_download_provider.dart'; + +class NumericalInputRow extends StatefulWidget { + const NumericalInputRow({ + super.key, + required this.label, + required this.suffixText, + required this.value, + required this.min, + required this.max, + this.maxEligibleTilesPreview, + required this.onChanged, + }); + + final String label; + final String suffixText; + final int Function(ConfigureDownloadProvider provider) value; + final int min; + final int? max; + final int? maxEligibleTilesPreview; + final void Function(ConfigureDownloadProvider provider, int value) onChanged; + + @override + State createState() => _NumericalInputRowState(); +} + +class _NumericalInputRowState extends State { + TextEditingController? tec; + + @override + Widget build(BuildContext context) => + Selector( + selector: (context, provider) => widget.value(provider), + builder: (context, currentValue, _) { + tec ??= TextEditingController(text: currentValue.toString()); + + return Row( + children: [ + Text(widget.label), + const Spacer(), + if (widget.maxEligibleTilesPreview != null) ...[ + IconButton( + icon: const Icon(Icons.visibility), + disabledColor: Colors.green, + tooltip: currentValue > widget.maxEligibleTilesPreview! + ? 'Tap to enable following download live' + : 'Eligible to follow download live', + onPressed: currentValue > widget.maxEligibleTilesPreview! + ? () { + widget.onChanged( + context.read(), + widget.maxEligibleTilesPreview!, + ); + tec!.text = widget.maxEligibleTilesPreview.toString(); + } + : null, + ), + const SizedBox(width: 8), + ], + if (widget.max != null) ...[ + Tooltip( + message: currentValue == widget.max + ? 'Limited in the example app' + : '', + child: Icon( + Icons.lock, + color: currentValue == widget.max + ? Colors.amber + : Colors.white.withOpacity(0.2), + ), + ), + const SizedBox(width: 16), + ], + IntrinsicWidth( + child: TextFormField( + controller: tec, + textAlign: TextAlign.end, + keyboardType: TextInputType.number, + decoration: InputDecoration( + isDense: true, + counterText: '', + suffixText: ' ${widget.suffixText}', + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + _NumericalRangeFormatter( + min: widget.min, + max: widget.max ?? 9223372036854775807, + ), + ], + onChanged: (newVal) => widget.onChanged( + context.read(), + int.tryParse(newVal) ?? currentValue, + ), + ), + ), + ], + ); + }, + ); +} + +class _NumericalRangeFormatter extends TextInputFormatter { + const _NumericalRangeFormatter({required this.min, required this.max}); + final int min; + final int max; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.isEmpty) return newValue; + + final int parsed = int.parse(newValue.text); + + if (parsed < min) { + return TextEditingValue.empty.copyWith( + text: min.toString(), + selection: TextSelection.collapsed(offset: min.toString().length), + ); + } + if (parsed > max) { + return TextEditingValue.empty.copyWith( + text: max.toString(), + selection: TextSelection.collapsed(offset: max.toString().length), + ); + } + + return newValue; + } +} diff --git a/example/lib/screens/configure_download/components/options_pane.dart b/example/lib/screens/configure_download/components/options_pane.dart new file mode 100644 index 00000000..1993455b --- /dev/null +++ b/example/lib/screens/configure_download/components/options_pane.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../shared/misc/exts/interleave.dart'; + +class OptionsPane extends StatelessWidget { + const OptionsPane({ + super.key, + required this.label, + required this.children, + this.interPadding = 8, + }); + + final String label; + final Iterable children; + final double interPadding; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 14), + child: Text(label), + ), + const SizedBox.square(dimension: 4), + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: children.singleOrNull ?? + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children + .interleave(SizedBox.square(dimension: interPadding)) + .toList(), + ), + ), + ), + ], + ); +} diff --git a/example/lib/screens/configure_download/components/region_information.dart b/example/lib/screens/configure_download/components/region_information.dart new file mode 100644 index 00000000..b661e49e --- /dev/null +++ b/example/lib/screens/configure_download/components/region_information.dart @@ -0,0 +1,249 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:dart_earcut/dart_earcut.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:intl/intl.dart'; +import 'package:latlong2/latlong.dart'; + +class RegionInformation extends StatefulWidget { + const RegionInformation({ + super.key, + required this.region, + required this.minZoom, + required this.maxZoom, + required this.startTile, + required this.endTile, + }); + + final BaseRegion region; + final int minZoom; + final int maxZoom; + final int startTile; + final int? endTile; + + @override + State createState() => _RegionInformationState(); +} + +class _RegionInformationState extends State { + final distance = const Distance(roundResult: false).distance; + + late Future numOfTiles; + + @override + void initState() { + super.initState(); + numOfTiles = const FMTCStore('').download.check( + widget.region.toDownloadable( + minZoom: widget.minZoom, + maxZoom: widget.maxZoom, + options: TileLayer(), + ), + ); + } + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...widget.region.when( + rectangle: (rectangle) => [ + const Text('TOTAL AREA'), + Text( + '${(distance(rectangle.bounds.northWest, rectangle.bounds.northEast) * distance(rectangle.bounds.northEast, rectangle.bounds.southEast) / 1000000).toStringAsFixed(3)} km²', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('APPROX. NORTH WEST'), + Text( + '${rectangle.bounds.northWest.latitude.toStringAsFixed(3)}, ${rectangle.bounds.northWest.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('APPROX. SOUTH EAST'), + Text( + '${rectangle.bounds.southEast.latitude.toStringAsFixed(3)}, ${rectangle.bounds.southEast.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ], + circle: (circle) => [ + const Text('TOTAL AREA'), + Text( + '${(pi * pow(circle.radius, 2)).toStringAsFixed(3)} km²', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('RADIUS'), + Text( + '${circle.radius.toStringAsFixed(2)} km', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('APPROX. CENTER'), + Text( + '${circle.center.latitude.toStringAsFixed(3)}, ${circle.center.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ], + line: (line) { + double totalDistance = 0; + + for (int i = 0; i < line.line.length - 1; i++) { + totalDistance += + distance(line.line[i], line.line[i + 1]); + } + + return [ + const Text('LINE LENGTH'), + Text( + '${(totalDistance / 1000).toStringAsFixed(3)} km', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('FIRST COORD'), + Text( + '${line.line[0].latitude.toStringAsFixed(3)}, ${line.line[0].longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('LAST COORD'), + Text( + '${line.line.last.latitude.toStringAsFixed(3)}, ${line.line.last.longitude.toStringAsFixed(3)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ]; + }, + customPolygon: (customPolygon) { + double area = 0; + + for (final triangle in Earcut.triangulateFromPoints( + customPolygon.outline + .map(const Epsg3857().projection.project), + ).map(customPolygon.outline.elementAt).slices(3)) { + final a = distance(triangle[0], triangle[1]); + final b = distance(triangle[1], triangle[2]); + final c = distance(triangle[2], triangle[0]); + + area += 0.25 * + sqrt( + 4 * a * a * b * b - pow(a * a + b * b - c * c, 2), + ); + } + + return [ + const Text('TOTAL AREA'), + Text( + '${(area / 1000000).toStringAsFixed(3)} km²', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ]; + }, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text('ZOOM LEVELS'), + Text( + '${widget.minZoom} - ${widget.maxZoom}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox(height: 10), + const Text('TOTAL TILES'), + FutureBuilder( + future: numOfTiles, + builder: (context, snapshot) => snapshot.data == null + ? Padding( + padding: const EdgeInsets.only(top: 4), + child: SizedBox( + height: 36, + width: 36, + child: Center( + child: SizedBox( + height: 28, + width: 28, + child: CircularProgressIndicator( + color: + Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + ) + : Text( + NumberFormat('###,###').format(snapshot.data), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ), + const SizedBox(height: 10), + const Text('TILES RANGE'), + if (widget.startTile == 1 && widget.endTile == null) + const Text( + '*', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ) + else + Text( + '${NumberFormat('###,###').format(widget.startTile)} - ${widget.endTile != null ? NumberFormat('###,###').format(widget.endTile) : '*'}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ], + ), + ], + ), + ], + ); +} diff --git a/example/lib/screens/configure_download/components/start_download_button.dart b/example/lib/screens/configure_download/components/start_download_button.dart new file mode 100644 index 00000000..10c7da60 --- /dev/null +++ b/example/lib/screens/configure_download/components/start_download_button.dart @@ -0,0 +1,135 @@ +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 new file mode 100644 index 00000000..ba28610f --- /dev/null +++ b/example/lib/screens/configure_download/components/store_selector.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../../shared/state/general_provider.dart'; +import '../../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 new file mode 100644 index 00000000..7ed25b95 --- /dev/null +++ b/example/lib/screens/configure_download/configure_download.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:provider/provider.dart'; + +import '../../shared/misc/exts/interleave.dart'; +import 'components/numerical_input_row.dart'; +import 'components/options_pane.dart'; +import 'components/region_information.dart'; +import 'components/start_download_button.dart'; +import 'components/store_selector.dart'; +import 'state/configure_download_provider.dart'; + +class ConfigureDownloadPopup extends StatelessWidget { + const ConfigureDownloadPopup({ + super.key, + required this.region, + required this.minZoom, + required this.maxZoom, + required this.startTile, + required this.endTile, + }); + + final BaseRegion region; + final int minZoom; + final int maxZoom; + final int startTile; + final int? endTile; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Configure Bulk Download')), + floatingActionButton: StartDownloadButton( + region: region, + minZoom: minZoom, + maxZoom: maxZoom, + startTile: startTile, + endTile: endTile, + ), + body: Stack( + fit: StackFit.expand, + children: [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox.shrink(), + RegionInformation( + region: region, + minZoom: minZoom, + maxZoom: maxZoom, + startTile: startTile, + endTile: endTile, + ), + const Divider(thickness: 2, height: 8), + const OptionsPane( + label: 'STORE DIRECTORY', + children: [StoreSelector()], + ), + OptionsPane( + label: 'PERFORMANCE FACTORS', + children: [ + NumericalInputRow( + label: 'Parallel Threads', + suffixText: 'threads', + value: (provider) => provider.parallelThreads, + min: 1, + max: 10, + onChanged: (provider, value) => + provider.parallelThreads = value, + ), + NumericalInputRow( + label: 'Rate Limit', + suffixText: 'max. tps', + value: (provider) => provider.rateLimit, + min: 1, + max: 300, + maxEligibleTilesPreview: 20, + onChanged: (provider, value) => + provider.rateLimit = value, + ), + NumericalInputRow( + label: 'Tile Buffer Length', + suffixText: 'max. tiles', + value: (provider) => provider.maxBufferLength, + min: 0, + max: null, + onChanged: (provider, value) => + provider.maxBufferLength = value, + ), + ], + ), + OptionsPane( + label: 'SKIP TILES', + children: [ + Row( + children: [ + const Text('Skip Existing Tiles'), + const Spacer(), + Switch.adaptive( + value: context + .select( + (provider) => provider.skipExistingTiles, + ), + onChanged: (val) => context + .read() + .skipExistingTiles = val, + activeColor: + Theme.of(context).colorScheme.primary, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Skip Sea Tiles'), + const Spacer(), + Switch.adaptive( + value: context.select((provider) => provider.skipSeaTiles), + onChanged: (val) => context + .read() + .skipSeaTiles = val, + activeColor: + Theme.of(context).colorScheme.primary, + ), + ], + ), + ], + ), + const SizedBox(height: 72), + ].interleave(const SizedBox.square(dimension: 16)).toList(), + ), + ), + ), + Selector( + selector: (context, provider) => provider.isReady, + builder: (context, isReady, _) => IgnorePointer( + ignoring: !isReady, + child: GestureDetector( + onTap: isReady + ? () => context + .read() + .isReady = false + : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInCubic, + color: isReady + ? Colors.black.withOpacity(2 / 3) + : Colors.transparent, + ), + ), + ), + ), + ], + ), + ); +} diff --git a/example/lib/screens/configure_download/state/configure_download_provider.dart b/example/lib/screens/configure_download/state/configure_download_provider.dart new file mode 100644 index 00000000..d7ce1387 --- /dev/null +++ b/example/lib/screens/configure_download/state/configure_download_provider.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; + +class ConfigureDownloadProvider extends ChangeNotifier { + static const defaultValues = { + 'parallelThreads': 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/download_region/components/bd_battery_optimizations_info.dart b/example/lib/screens/download_region/components/bd_battery_optimizations_info.dart deleted file mode 100644 index ac14bb86..00000000 --- a/example/lib/screens/download_region/components/bd_battery_optimizations_info.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:fmtc_plus_background_downloading/fmtc_plus_background_downloading.dart'; - -class BackgroundDownloadBatteryOptimizationsInfo extends StatefulWidget { - const BackgroundDownloadBatteryOptimizationsInfo({ - super.key, - }); - - @override - State createState() => - _BackgroundDownloadBatteryOptimizationsInfoState(); -} - -class _BackgroundDownloadBatteryOptimizationsInfoState - extends State { - @override - Widget build(BuildContext context) => FutureBuilder( - future: FMTC - .instance('') - .download - .requestIgnoreBatteryOptimizations(requestIfDenied: false), - builder: (context, snapshot) => Row( - children: [ - Icon( - snapshot.data == null || !snapshot.data! - ? Icons.warning_amber - : Icons.done, - color: snapshot.data == null || !snapshot.data! - ? Colors.amber - : Colors.green, - size: 36, - ), - const SizedBox(width: 15), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Apps that support background downloading can request extra permissions to help prevent the background process being stopped by the system. Specifically, the 'ignore battery optimisations' permission helps most. The API has a method to manage this permission.", - textAlign: TextAlign.justify, - ), - const SizedBox(height: 10), - Text( - snapshot.hasError - ? 'This platform currently does not support this API: it is only supported on Android.' - : snapshot.data == null - ? 'Checking if this permission is currently granted to this application...' - : (!snapshot.data! - ? 'This application does not have this permission granted to it currently. Tap the button below to use the API method to request the permission.' - : 'This application does currently have this permission granted to it.'), - textAlign: TextAlign.justify, - ), - if (!(snapshot.data ?? true)) - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () async { - await FMTC - .instance('') - .download - .requestIgnoreBatteryOptimizations(); - setState(() {}); - }, - child: const Text('Request Permission'), - ), - ), - ], - ), - ), - ], - ), - ); -} diff --git a/example/lib/screens/download_region/components/buffering_configuration.dart b/example/lib/screens/download_region/components/buffering_configuration.dart deleted file mode 100644 index 1fa99fef..00000000 --- a/example/lib/screens/download_region/components/buffering_configuration.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:provider/provider.dart'; - -import '../../../shared/state/download_provider.dart'; - -class BufferingConfiguration extends StatelessWidget { - const BufferingConfiguration({super.key}); - - @override - Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('BUFFERING CONFIGURATION'), - const SizedBox(height: 15), - if (provider.regionTiles == null) - const CircularProgressIndicator() - else ...[ - Row( - children: [ - SegmentedButton( - segments: const [ - ButtonSegment( - value: DownloadBufferMode.disabled, - label: Text('Disabled'), - icon: Icon(Icons.cancel), - ), - ButtonSegment( - value: DownloadBufferMode.tiles, - label: Text('Tiles'), - icon: Icon(Icons.flip_to_front_outlined), - ), - ButtonSegment( - value: DownloadBufferMode.bytes, - label: Text('Size (kB)'), - icon: Icon(Icons.storage_rounded), - ), - ], - selected: {provider.bufferMode}, - onSelectionChanged: (s) => provider.bufferMode = s.single, - ), - const SizedBox(width: 20), - provider.bufferMode == DownloadBufferMode.disabled - ? const SizedBox.shrink() - : Text( - provider.bufferMode == DownloadBufferMode.tiles && - provider.bufferingAmount >= - provider.regionTiles! - ? 'Write Once' - : '${provider.bufferingAmount} ${provider.bufferMode == DownloadBufferMode.tiles ? 'tiles' : 'kB'}', - ), - ], - ), - const SizedBox(height: 5), - provider.bufferMode == DownloadBufferMode.disabled - ? const Slider(value: 0.5, onChanged: null) - : Slider( - value: provider.bufferMode == DownloadBufferMode.tiles - ? provider.bufferingAmount - .clamp(10, provider.regionTiles!) - .roundToDouble() - : provider.bufferingAmount.roundToDouble(), - min: provider.bufferMode == DownloadBufferMode.tiles - ? 10 - : 500, - max: provider.bufferMode == DownloadBufferMode.tiles - ? provider.regionTiles!.toDouble() - : 10000, - onChanged: (value) => - provider.bufferingAmount = value.round(), - ), - ], - ], - ), - ); -} diff --git a/example/lib/screens/download_region/components/optional_functionality.dart b/example/lib/screens/download_region/components/optional_functionality.dart deleted file mode 100644 index 4ecebf98..00000000 --- a/example/lib/screens/download_region/components/optional_functionality.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../shared/state/download_provider.dart'; - -class OptionalFunctionality extends StatelessWidget { - const OptionalFunctionality({super.key}); - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('OPTIONAL FUNCTIONALITY'), - Consumer( - builder: (context, provider, _) => Column( - children: [ - Row( - children: [ - const Text('Only Download New Tiles'), - const Spacer(), - IconButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - '`preventRedownload` within API. Controls whether the script will re-download tiles that already exist or not.', - ), - duration: Duration(seconds: 8), - ), - ); - }, - icon: const Icon(Icons.help_outline), - ), - Switch( - value: provider.preventRedownload, - onChanged: (val) => provider.preventRedownload = val, - activeColor: Theme.of(context).colorScheme.primary, - ) - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Remove Sea Tiles'), - const Spacer(), - IconButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - '`seaTileRemoval` within API. Deletes tiles that are pure sea - tiles that match the tile at x=0, y=0, z=19 exactly. Note that this saves storage space, but not time or data: tiles still have to be downloaded to be matched. Not supported on satelite servers.', - ), - duration: Duration(seconds: 8), - ), - ); - }, - icon: const Icon(Icons.help_outline), - ), - Switch( - value: provider.seaTileRemoval, - onChanged: (val) => provider.seaTileRemoval = val, - activeColor: Theme.of(context).colorScheme.primary, - ) - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Disable Recovery'), - const Spacer(), - IconButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Disables automatic recovery. Use only for testing or in special circumstances.', - ), - duration: Duration(seconds: 8), - ), - ); - }, - icon: const Icon(Icons.help_outline), - ), - Switch( - value: provider.disableRecovery, - onChanged: (val) async { - if (val) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'This option is not recommended, use with caution', - ), - duration: Duration(seconds: 8), - ), - ); - } - provider.disableRecovery = val; - }, - activeColor: Colors.amber, - ) - ], - ), - ], - ), - ), - ], - ); -} diff --git a/example/lib/screens/download_region/components/region_information.dart b/example/lib/screens/download_region/components/region_information.dart deleted file mode 100644 index 5b16556c..00000000 --- a/example/lib/screens/download_region/components/region_information.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; - -import '../../../shared/state/download_provider.dart'; -import '../download_region.dart'; - -class RegionInformation extends StatelessWidget { - const RegionInformation({ - super.key, - required this.widget, - required this.circleRegion, - required this.rectangleRegion, - }); - - final DownloadRegionPopup widget; - final CircleRegion? circleRegion; - final RectangleRegion? rectangleRegion; - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.region is CircleRegion) ...[ - const Text('APPROX. CENTER'), - Text( - '${circleRegion!.center.latitude.toStringAsFixed(3)}, ${circleRegion!.center.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('RADIUS'), - Text( - '${circleRegion!.radius.toStringAsFixed(2)} km', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ] else ...[ - const Text('APPROX. NORTH WEST'), - Text( - '${rectangleRegion!.bounds.northWest.latitude.toStringAsFixed(3)}, ${rectangleRegion!.bounds.northWest.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const SizedBox(height: 10), - const Text('APPROX. SOUTH EAST'), - Text( - '${rectangleRegion!.bounds.southEast.latitude.toStringAsFixed(3)}, ${rectangleRegion!.bounds.southEast.longitude.toStringAsFixed(3)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ], - const SizedBox(height: 10), - const Text('MIN/MAX ZOOM LEVELS'), - Consumer( - builder: (context, provider, _) => - provider.regionTiles == 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( - '${provider.minZoom} - ${provider.maxZoom}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const Text('TOTAL TILES'), - Consumer( - builder: (context, provider, _) => - provider.regionTiles == 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(provider.regionTiles), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ), - ], - ), - ], - ), - ], - ); -} diff --git a/example/lib/screens/download_region/components/section_separator.dart b/example/lib/screens/download_region/components/section_separator.dart deleted file mode 100644 index c2ecb1d4..00000000 --- a/example/lib/screens/download_region/components/section_separator.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter/material.dart'; - -class SectionSeparator extends StatelessWidget { - const SectionSeparator({super.key}); - - @override - Widget build(BuildContext context) => Column( - children: const [ - SizedBox(height: 5), - Divider(), - SizedBox(height: 5), - ], - ); -} diff --git a/example/lib/screens/download_region/components/store_selector.dart b/example/lib/screens/download_region/components/store_selector.dart deleted file mode 100644 index 3fdb1c5f..00000000 --- a/example/lib/screens/download_region/components/store_selector.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 '../../../shared/state/download_provider.dart'; -import '../../../shared/state/general_provider.dart'; - -class StoreSelector extends StatefulWidget { - const StoreSelector({super.key}); - - @override - State createState() => _StoreSelectorState(); -} - -class _StoreSelectorState extends State { - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('CHOOSE A STORE'), - Consumer2( - builder: (context, downloadProvider, generalProvider, _) => - FutureBuilder>( - future: FMTC.instance.rootDirectory.stats.storesAvailableAsync, - 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 - : FMTC.instance(generalProvider.currentStore!)), - isExpanded: true, - hint: Text( - snapshot.data == null - ? 'Loading...' - : snapshot.data!.isEmpty - ? 'None Available' - : 'None Selected', - ), - ), - ), - ), - ], - ); -} diff --git a/example/lib/screens/download_region/components/usage_warning.dart b/example/lib/screens/download_region/components/usage_warning.dart deleted file mode 100644 index 30d20a1c..00000000 --- a/example/lib/screens/download_region/components/usage_warning.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -class UsageWarning extends StatelessWidget { - const UsageWarning({ - super.key, - }); - - @override - Widget build(BuildContext context) => Row( - children: const [ - Icon( - Icons.warning_amber, - color: Colors.red, - size: 36, - ), - SizedBox(width: 15), - Expanded( - child: 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.\nThis example application is limited to a maximum of 2 simultaneous download threads by default.", - textAlign: TextAlign.justify, - ), - ), - ], - ); -} diff --git a/example/lib/screens/download_region/download_region.dart b/example/lib/screens/download_region/download_region.dart deleted file mode 100644 index a54471a5..00000000 --- a/example/lib/screens/download_region/download_region.dart +++ /dev/null @@ -1,198 +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:fmtc_plus_background_downloading/fmtc_plus_background_downloading.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../shared/state/download_provider.dart'; -import '../../shared/state/general_provider.dart'; -import 'components/bd_battery_optimizations_info.dart'; -import 'components/buffering_configuration.dart'; -import 'components/optional_functionality.dart'; -import 'components/region_information.dart'; -import 'components/section_separator.dart'; -import 'components/store_selector.dart'; -import 'components/usage_warning.dart'; - -class DownloadRegionPopup extends StatefulWidget { - const DownloadRegionPopup({ - super.key, - required this.region, - }); - - final BaseRegion region; - - @override - State createState() => _DownloadRegionPopupState(); -} - -class _DownloadRegionPopupState extends State { - late final CircleRegion? circleRegion; - late final RectangleRegion? rectangleRegion; - - @override - void initState() { - if (widget.region is CircleRegion) { - circleRegion = widget.region as CircleRegion; - rectangleRegion = null; - } else { - rectangleRegion = widget.region as RectangleRegion; - circleRegion = null; - } - - super.initState(); - } - - @override - void didChangeDependencies() { - final String? currentStore = - Provider.of(context, listen: false).currentStore; - if (currentStore != null) { - Provider.of(context, listen: false) - .setSelectedStore(FMTC.instance(currentStore), notify: false); - } - - super.didChangeDependencies(); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Download Region'), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RegionInformation( - widget: widget, - circleRegion: circleRegion, - rectangleRegion: rectangleRegion, - ), - const SectionSeparator(), - const StoreSelector(), - const SectionSeparator(), - const OptionalFunctionality(), - const SectionSeparator(), - const BufferingConfiguration(), - const SectionSeparator(), - const BackgroundDownloadBatteryOptimizationsInfo(), - const SectionSeparator(), - const UsageWarning(), - const SectionSeparator(), - const Text('START DOWNLOAD IN'), - Consumer2( - builder: (context, downloadProvider, generalProvider, _) => - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: ElevatedButton( - onPressed: downloadProvider.selectedStore == null - ? null - : () async { - final Map metadata = - await downloadProvider - .selectedStore!.metadata.readAsync; - - downloadProvider.setDownloadProgress( - downloadProvider.selectedStore!.download - .startForeground( - region: widget.region.toDownloadable( - downloadProvider.minZoom, - downloadProvider.maxZoom, - TileLayer( - urlTemplate: - metadata['sourceURL'], - ), - preventRedownload: downloadProvider - .preventRedownload, - seaTileRemoval: - downloadProvider.seaTileRemoval, - parallelThreads: - (await SharedPreferences - .getInstance()) - .getBool( - 'bypassDownloadThreadsLimitation', - ) ?? - false - ? 10 - : 2, - ), - disableRecovery: - downloadProvider.disableRecovery, - bufferMode: - downloadProvider.bufferMode, - bufferLimit: downloadProvider - .bufferMode == - DownloadBufferMode.tiles - ? downloadProvider.bufferingAmount - : downloadProvider - .bufferingAmount * - 1000, - ) - .asBroadcastStream(), - ); - - if (mounted) Navigator.of(context).pop(); - }, - child: const Text('Foreground'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton( - onPressed: downloadProvider.selectedStore == null - ? null - : () async { - final Map metadata = - await downloadProvider - .selectedStore!.metadata.readAsync; - - await downloadProvider.selectedStore!.download - .startBackground( - region: widget.region.toDownloadable( - downloadProvider.minZoom, - downloadProvider.maxZoom, - TileLayer( - urlTemplate: metadata['sourceURL'], - ), - preventRedownload: - downloadProvider.preventRedownload, - seaTileRemoval: - downloadProvider.seaTileRemoval, - parallelThreads: (await SharedPreferences - .getInstance()) - .getBool( - 'bypassDownloadThreadsLimitation', - ) ?? - false - ? 10 - : 2, - ), - disableRecovery: - downloadProvider.disableRecovery, - backgroundNotificationIcon: - const AndroidResource( - name: 'ic_notification_icon', - defType: 'mipmap', - ), - ); - - if (mounted) Navigator.of(context).pop(); - }, - child: const Text('Background'), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); -} diff --git a/example/lib/screens/export_import/components/directory_selected.dart b/example/lib/screens/export_import/components/directory_selected.dart new file mode 100644 index 00000000..e7cf2362 --- /dev/null +++ b/example/lib/screens/export_import/components/directory_selected.dart @@ -0,0 +1,17 @@ +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 new file mode 100644 index 00000000..5ec8add0 --- /dev/null +++ b/example/lib/screens/export_import/components/export.dart @@ -0,0 +1,84 @@ +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 new file mode 100644 index 00000000..03b681b4 --- /dev/null +++ b/example/lib/screens/export_import/components/import.dart @@ -0,0 +1,208 @@ +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 new file mode 100644 index 00000000..7e4b6b35 --- /dev/null +++ b/example/lib/screens/export_import/components/no_path_selected.dart @@ -0,0 +1,17 @@ +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 new file mode 100644 index 00000000..ab42d2ae --- /dev/null +++ b/example/lib/screens/export_import/components/path_picker.dart @@ -0,0 +1,149 @@ +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 new file mode 100644 index 00000000..d0302f36 --- /dev/null +++ b/example/lib/screens/export_import/export_import.dart @@ -0,0 +1,236 @@ +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/import_store/import_store.dart b/example/lib/screens/import_store/import_store.dart deleted file mode 100644 index c6d3edff..00000000 --- a/example/lib/screens/import_store/import_store.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:fmtc_plus_sharing/fmtc_plus_sharing.dart'; - -class ImportStorePopup extends StatefulWidget { - const ImportStorePopup({super.key}); - - @override - State createState() => _ImportStorePopupState(); -} - -class _ImportStorePopupState extends State { - final Map importStores = {}; - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Import Stores'), - ), - body: Padding( - padding: const EdgeInsets.all(12), - child: ListView.separated( - itemCount: importStores.length + 1, - itemBuilder: (context, i) { - if (i == importStores.length) { - return ListTile( - leading: const Icon(Icons.add), - title: const Text('Choose New Store(s)'), - subtitle: const Text('Select any valid store files (.fmtc)'), - onTap: () async { - importStores.addAll( - (await FMTC.instance.rootDirectory.import.withGUI( - collisionHandler: (fn, sn) { - setState( - () => importStores[fn]!.collisionInfo = [ - fn, - sn, - ], - ); - return importStores[fn]! - .collisionResolution - .future; - }, - ) ?? - {}) - .map( - (name, status) => MapEntry( - name, - _ImportStore(status, collisionInfo: null), - ), - ), - ); - if (mounted) setState(() {}); - }, - ); - } - - final filename = importStores.keys.toList()[i]; - return FutureBuilder( - future: importStores[filename]?.result, - builder: (context, s1) => FutureBuilder( - future: importStores[filename]?.collisionResolution.future, - builder: (context, s2) { - final result = s1.data; - final conflict = s2.data; - - T stateSwitcher({ - required T loading, - required T successful, - required T failed, - required T cancelled, - required T collided, - }) { - if (importStores[filename]!.collisionInfo != null && - conflict == null) return collided; - if (conflict == false) return cancelled; - if (result == null) return loading; - return result.successful ? successful : failed; - } - - final storeName = result?.storeName; - - return ListTile( - leading: stateSwitcher( - loading: const CircularProgressIndicator.adaptive(), - successful: const Icon(Icons.done, color: Colors.green), - failed: const Icon(Icons.error, color: Colors.red), - cancelled: const Icon(Icons.cancel), - collided: - const Icon(Icons.merge_type, color: Colors.amber), - ), - title: Text(filename), - subtitle: stateSwitcher( - loading: const Text('Loading...'), - successful: Text('Imported as: $storeName'), - failed: null, - cancelled: null, - collided: Text( - 'Collision with ${importStores[filename]!.collisionInfo?[1]}', - ), - ), - trailing: importStores[filename]!.collisionInfo != null && - conflict == null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => importStores[filename]! - .collisionResolution - .complete(true), - icon: const Icon(Icons.edit), - tooltip: 'Overwrite store', - ), - IconButton( - onPressed: () => importStores[filename]! - .collisionResolution - .complete(false), - icon: const Icon(Icons.cancel), - tooltip: 'Cancel import', - ), - ], - ) - : null, - ); - }, - ), - ); - }, - separatorBuilder: (context, i) => i == importStores.length - 1 - ? const Divider() - : const SizedBox.shrink(), - ), - ), - ); -} - -class _ImportStore { - final Future result; - List? collisionInfo; - Completer collisionResolution; - - _ImportStore( - this.result, { - required this.collisionInfo, - }) : collisionResolution = Completer(); -} diff --git a/example/lib/screens/initialisation_error/initialisation_error.dart b/example/lib/screens/initialisation_error/initialisation_error.dart new file mode 100644 index 00000000..7cae2a7b --- /dev/null +++ b/example/lib/screens/initialisation_error/initialisation_error.dart @@ -0,0 +1,105 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +import '../../main.dart'; + +class InitialisationError extends StatelessWidget { + const InitialisationError({super.key, required this.err}); + + final Object? err; + + @override + Widget build(BuildContext context) => Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, size: 64), + const SizedBox(height: 12), + Text( + 'Whoops, look like FMTC ran into an error initialising', + style: Theme.of(context) + .textTheme + .displaySmall! + .copyWith(color: Colors.white), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + SelectableText( + 'Type: ${err.runtimeType}', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + SelectableText( + 'Error: $err', + style: Theme.of(context).textTheme.bodyLarge, + 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( + onPressed: () async { + void showFailure() { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Unfortuately, that didn't work. Try clearing " + "the app's storage and cache manually.", + ), + ), + ); + } + } + + final dir = Directory( + path.join( + (await getApplicationDocumentsDirectory()) + .absolute + .path, + 'fmtc', + ), + ); + + if (!await dir.exists()) { + showFailure(); + return; + } + + try { + await dir.delete(recursive: true); + } on FileSystemException { + showFailure(); + rethrow; + } + + runApp(const SizedBox.shrink()); + + main(); + }, + child: const Text( + 'Reset FMTC & attempt re-initialisation', + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ); +} diff --git a/example/lib/screens/main/main.dart b/example/lib/screens/main/main.dart index b5e9601a..bb756d9e 100644 --- a/example/lib/screens/main/main.dart +++ b/example/lib/screens/main/main.dart @@ -1,121 +1,98 @@ -import 'dart:io'; - 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:fmtc_plus_background_downloading/fmtc_plus_background_downloading.dart'; import 'package:provider/provider.dart'; -import '../../shared/state/download_provider.dart'; -import 'pages/downloader/downloader.dart'; import 'pages/downloading/downloading.dart'; -import 'pages/map/map_view.dart'; +import 'pages/downloading/state/downloading_provider.dart'; +import 'pages/map/map_page.dart'; import 'pages/recovery/recovery.dart'; -import 'pages/settingsAndAbout/settings_and_about.dart'; +import 'pages/region_selection/region_selection.dart'; import 'pages/stores/stores.dart'; -import 'pages/update/update.dart'; class MainScreen extends StatefulWidget { - const MainScreen({ - super.key, - required this.damagedDatabaseDeleted, - }); - - final String? damagedDatabaseDeleted; + const MainScreen({super.key}); @override State createState() => _MainScreenState(); } class _MainScreenState extends State { - //static const Color backgroundColor = Color(0xFFeaf6f5); - late final PageController _pageController; + late final _pageController = PageController(initialPage: _currentPageIndex); int _currentPageIndex = 0; bool extended = false; List get _destinations => [ const NavigationDestination( - icon: Icon(Icons.map), label: 'Map', + icon: Icon(Icons.map_outlined), + selectedIcon: Icon(Icons.map), ), const NavigationDestination( - icon: Icon(Icons.folder), label: 'Stores', + icon: Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder), ), const NavigationDestination( - icon: Icon(Icons.download), label: 'Download', + icon: Icon(Icons.download_outlined), + selectedIcon: Icon(Icons.download), ), NavigationDestination( + label: 'Recover', icon: StreamBuilder( - stream: FMTC.instance.rootDirectory.stats - .watchChanges() - .asBroadcastStream(), - builder: (context, _) => FutureBuilder>( - future: FMTC.instance.rootDirectory.recovery.failedRegions, + 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?.isNotEmpty ?? false), - child: const Icon(Icons.running_with_errors), + (snapshot.data?.failedOnly.isNotEmpty ?? false), + child: const Icon(Icons.support), ), ), ), - label: 'Recover', - ), - const NavigationDestination( - icon: Icon(Icons.settings), - label: 'Settings', ), - if (Platform.isWindows || Platform.isAndroid) - const NavigationDestination( - icon: Icon(Icons.update), - label: 'Update', - ), ]; List get _pages => [ const MapPage(), const StoresPage(), - Consumer( - builder: (context, provider, _) => provider.downloadProgress == null - ? const DownloaderPage() - : const DownloadingPage(), + Selector?>( + selector: (context, provider) => provider.downloadProgress, + builder: (context, downloadProgress, _) => downloadProgress == null + ? const RegionSelectionPage() + : DownloadingPage( + moveToMapPage: () => + _onDestinationSelected(0, cancelTilesPreview: false), + ), ), RecoveryPage(moveToDownloadPage: () => _onDestinationSelected(2)), - const SettingsAndAboutPage(), - if (Platform.isWindows || Platform.isAndroid) const UpdatePage(), ]; - void _onDestinationSelected(int index) { + void _onDestinationSelected(int index, {bool cancelTilesPreview = true}) { setState(() => _currentPageIndex = index); - _pageController.animateToPage( + _pageController + .animateToPage( _currentPageIndex, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut, + ) + .then( + (_) { + if (cancelTilesPreview) { + final dp = context.read(); + dp.tilesPreviewStreamSub + ?.cancel() + .then((_) => dp.tilesPreviewStreamSub = null); + } + }, ); } - @override - void initState() { - _pageController = PageController(initialPage: _currentPageIndex); - if (widget.damagedDatabaseDeleted != null) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'At least one corrupted database has been deleted.\n${widget.damagedDatabaseDeleted}', - ), - ), - ), - ); - } - super.initState(); - } - @override void dispose() { _pageController.dispose(); @@ -123,80 +100,57 @@ class _MainScreenState extends State { } @override - Widget build(BuildContext context) => FMTCBackgroundDownload( - child: Scaffold( - bottomNavigationBar: MediaQuery.of(context).size.width > 950 - ? null - : NavigationBar( - backgroundColor: - Theme.of(context).navigationBarTheme.backgroundColor, - onDestinationSelected: _onDestinationSelected, - selectedIndex: _currentPageIndex, - destinations: _destinations, - labelBehavior: MediaQuery.of(context).size.width > 450 - ? null - : NavigationDestinationLabelBehavior.alwaysHide, - height: 70, - ), - body: Row( - children: [ - if (MediaQuery.of(context).size.width > 950) - NavigationRail( - onDestinationSelected: _onDestinationSelected, - selectedIndex: _currentPageIndex, - groupAlignment: 0, - extended: extended, - destinations: _destinations - .map( - (d) => NavigationRailDestination( - icon: d.icon, - label: Text(d.label), - padding: const EdgeInsets.all(10), - ), - ) - .toList(), - leading: Row( - children: [ - AnimatedContainer( - width: extended ? 205 : 0, - duration: kThemeAnimationDuration, - curve: Curves.easeInOut, - ), - IconButton( - icon: AnimatedSwitcher( - duration: kThemeAnimationDuration, - switchInCurve: Curves.easeInOut, - switchOutCurve: Curves.easeInOut, - child: Icon( - extended ? Icons.menu_open : Icons.menu, - key: UniqueKey(), - ), + 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, ), - onPressed: () => setState(() => extended = !extended), - tooltip: !extended ? 'Extend Menu' : 'Collapse Menu', ), - ], + ) + .toList(), + ), + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + left: MediaQuery.sizeOf(context).width > 950 + ? BorderSide(color: Theme.of(context).dividerColor) + : BorderSide.none, ), ), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.only( - topLeft: MediaQuery.of(context).size.width > 950 - ? const Radius.circular(16) - : Radius.zero, - bottomLeft: MediaQuery.of(context).size.width > 950 - ? const Radius.circular(16) - : Radius.zero, - ), - child: PageView( - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), - children: _pages, - ), + position: DecorationPosition.foreground, + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: _pages, ), ), - ], - ), + ), + ], ), ); } diff --git a/example/lib/screens/main/pages/downloader/components/header.dart b/example/lib/screens/main/pages/downloader/components/header.dart deleted file mode 100644 index 08fb0131..00000000 --- a/example/lib/screens/main/pages/downloader/components/header.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../shared/state/download_provider.dart'; -import '../../../../../shared/state/general_provider.dart'; -import 'min_max_zoom_controller_popup.dart'; -import 'shape_controller_popup.dart'; - -class Header extends StatelessWidget { - const Header({ - super.key, - }); - - @override - Widget build(BuildContext context) => Row( - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Downloader', - style: GoogleFonts.openSans( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - Consumer( - builder: (context, provider, _) => provider.currentStore == null - ? const SizedBox.shrink() - : const Text( - 'Existing tiles will appear in red', - style: TextStyle(fontStyle: FontStyle.italic), - ), - ), - ], - ), - const Spacer(), - IconButton( - onPressed: () => showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - builder: (_) => const MinMaxZoomControllerPopup(), - ).then( - (_) => Provider.of(context, listen: false) - .triggerManualPolygonRecalc(), - ), - icon: const Icon(Icons.zoom_in), - ), - IconButton( - onPressed: () => showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - builder: (_) => const ShapeControllerPopup(), - ).then( - (_) => Provider.of(context, listen: false) - .triggerManualPolygonRecalc(), - ), - icon: const Icon(Icons.select_all), - ), - ], - ); -} diff --git a/example/lib/screens/main/pages/downloader/components/map_view.dart b/example/lib/screens/main/pages/downloader/components/map_view.dart deleted file mode 100644 index 31889aa7..00000000 --- a/example/lib/screens/main/pages/downloader/components/map_view.dart +++ /dev/null @@ -1,328 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -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 'package:stream_transform/stream_transform.dart'; - -import '../../../../../shared/components/loading_indicator.dart'; -import '../../../../../shared/state/download_provider.dart'; -import '../../../../../shared/state/general_provider.dart'; -import '../../../../../shared/vars/region_mode.dart'; -import '../../map/build_attribution.dart'; -import 'crosshairs.dart'; - -class MapView extends StatefulWidget { - const MapView({ - super.key, - }); - - @override - State createState() => _MapViewState(); -} - -class _MapViewState extends State { - static const double _shapePadding = 15; - static const _crosshairsMovement = Point(10, 10); - - final _mapKey = GlobalKey>(); - final MapController _mapController = MapController(); - - late final StreamSubscription _polygonVisualizerStream; - late final StreamSubscription _tileCounterTriggerStream; - late final StreamSubscription _manualPolygonRecalcTriggerStream; - - Point? _crosshairsTop; - Point? _crosshairsBottom; - LatLng? _coordsTopLeft; - LatLng? _coordsBottomRight; - LatLng? _center; - double? _radius; - - PolygonLayer _buildTargetPolygon(BaseRegion region) => PolygonLayer( - polygons: [ - Polygon( - points: [ - LatLng(-90, 180), - LatLng(90, 180), - LatLng(90, -180), - LatLng(-90, -180), - ], - holePointsList: [region.toOutline()], - isFilled: true, - borderColor: Colors.black, - borderStrokeWidth: 2, - color: Theme.of(context).colorScheme.background.withOpacity(2 / 3), - ), - ], - ); - - @override - void initState() { - super.initState(); - - _manualPolygonRecalcTriggerStream = - Provider.of(context, listen: false) - .manualPolygonRecalcTrigger - .stream - .listen((_) { - _updatePointLatLng(); - _countTiles(); - }); - - _polygonVisualizerStream = - _mapController.mapEventStream.listen((_) => _updatePointLatLng()); - _tileCounterTriggerStream = _mapController.mapEventStream - .debounce(const Duration(seconds: 1)) - .listen((_) => _countTiles()); - } - - @override - void dispose() { - super.dispose(); - - _polygonVisualizerStream.cancel(); - _tileCounterTriggerStream.cancel(); - _manualPolygonRecalcTriggerStream.cancel(); - } - - @override - Widget build(BuildContext context) => - Consumer2( - key: _mapKey, - builder: (context, generalProvider, downloadProvider, _) => - FutureBuilder?>( - future: generalProvider.currentStore == null - ? Future.sync(() => {}) - : FMTC.instance(generalProvider.currentStore!).metadata.readAsync, - builder: (context, metadata) { - if (!metadata.hasData || - metadata.data == null || - (generalProvider.currentStore != null && - (metadata.data ?? {}).isEmpty)) { - return const LoadingIndicator( - message: - 'Loading Settings...\n\nSeeing this screen for a long time?\nThere may be a misconfiguration of the\nstore. Try disabling caching and deleting\n faulty stores.', - ); - } - - final String urlTemplate = - generalProvider.currentStore != null && metadata.data != null - ? metadata.data!['sourceURL']! - : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; - - return Stack( - children: [ - FlutterMap( - mapController: _mapController, - options: MapOptions( - center: LatLng(51.509364, -0.128928), - zoom: 9.2, - maxZoom: 22, - maxBounds: LatLngBounds.fromPoints([ - LatLng(-90, 180), - LatLng(90, 180), - LatLng(90, -180), - LatLng(-90, -180), - ]), - interactiveFlags: - InteractiveFlag.all & ~InteractiveFlag.rotate, - scrollWheelVelocity: 0.002, - keepAlive: true, - onMapReady: () { - _updatePointLatLng(); - _countTiles(); - }, - ), - nonRotatedChildren: buildStdAttribution( - urlTemplate, - alignment: AttributionAlignment.bottomLeft, - ), - children: [ - TileLayer( - urlTemplate: urlTemplate, - maxZoom: 20, - reset: generalProvider.resetController.stream, - keepBuffer: 5, - backgroundColor: const Color(0xFFaad3df), - tileBuilder: (context, widget, tile) => - FutureBuilder( - future: generalProvider.currentStore == null - ? Future.sync(() => null) - : FMTC - .instance(generalProvider.currentStore!) - .getTileProvider() - .checkTileCachedAsync( - coords: tile.coordinates, - options: TileLayer( - urlTemplate: urlTemplate, - ), - ), - builder: (context, snapshot) => DecoratedBox( - position: DecorationPosition.foreground, - decoration: BoxDecoration( - color: (snapshot.data ?? false) - ? Colors.deepOrange.withOpacity(0.33) - : Colors.transparent, - ), - child: widget, - ), - ), - ), - if (_coordsTopLeft != null && - _coordsBottomRight != null && - downloadProvider.regionMode != RegionMode.circle) - _buildTargetPolygon( - RectangleRegion( - LatLngBounds(_coordsTopLeft!, _coordsBottomRight!), - ), - ) - else if (_center != null && - _radius != null && - downloadProvider.regionMode == RegionMode.circle) - _buildTargetPolygon(CircleRegion(_center!, _radius!)) - ], - ), - if (_crosshairsTop != null && _crosshairsBottom != null) ...[ - Positioned( - top: _crosshairsTop!.y, - left: _crosshairsTop!.x, - child: const Crosshairs(), - ), - Positioned( - top: _crosshairsBottom!.y, - left: _crosshairsBottom!.x, - child: const Crosshairs(), - ), - ] - ], - ); - }, - ), - ); - - void _updatePointLatLng() { - final DownloadProvider downloadProvider = - Provider.of(context, listen: false); - - final Size mapSize = _mapKey.currentContext!.size!; - final bool isHeightLongestSide = mapSize.width < mapSize.height; - - final centerNormal = Point(mapSize.width / 2, mapSize.height / 2); - final centerInversed = Point(mapSize.height / 2, mapSize.width / 2); - - late final Point calculatedTopLeft; - late final Point calculatedBottomRight; - - switch (downloadProvider.regionMode) { - case RegionMode.square: - final double offset = (mapSize.shortestSide - (_shapePadding * 2)) / 2; - - calculatedTopLeft = Point( - centerNormal.x - offset, - centerNormal.y - offset, - ); - calculatedBottomRight = Point( - centerNormal.x + offset, - centerNormal.y + offset, - ); - break; - case RegionMode.rectangleVertical: - final allowedArea = Size( - mapSize.width - (_shapePadding * 2), - (mapSize.height - (_shapePadding * 2)) / 1.5 - 50, - ); - - calculatedTopLeft = Point( - centerInversed.y - allowedArea.shortestSide / 2, - _shapePadding, - ); - calculatedBottomRight = Point( - centerInversed.y + allowedArea.shortestSide / 2, - mapSize.height - _shapePadding - 25, - ); - break; - case RegionMode.rectangleHorizontal: - final allowedArea = Size( - mapSize.width - (_shapePadding * 2), - (mapSize.width < mapSize.height + 250) - ? (mapSize.width - (_shapePadding * 2)) / 1.75 - : (mapSize.height - (_shapePadding * 2) - 0), - ); - - calculatedTopLeft = Point( - _shapePadding, - centerNormal.y - allowedArea.height / 2, - ); - calculatedBottomRight = Point( - mapSize.width - _shapePadding, - centerNormal.y + allowedArea.height / 2 - 25, - ); - break; - case RegionMode.circle: - final allowedArea = - Size.square(mapSize.shortestSide - (_shapePadding * 2)); - - final calculatedTop = Point( - centerNormal.x, - (isHeightLongestSide ? centerNormal.y : centerInversed.x) - - allowedArea.width / 2, - ); - - _crosshairsTop = calculatedTop - _crosshairsMovement; - _crosshairsBottom = centerNormal - _crosshairsMovement; - - _center = - _mapController.pointToLatLng(_customPointFromPoint(centerNormal)); - _radius = const Distance(roundResult: false).distance( - _center!, - _mapController - .pointToLatLng(_customPointFromPoint(calculatedTop))!, - ) / - 1000; - setState(() {}); - break; - } - - if (downloadProvider.regionMode != RegionMode.circle) { - _crosshairsTop = calculatedTopLeft - _crosshairsMovement; - _crosshairsBottom = calculatedBottomRight - _crosshairsMovement; - - _coordsTopLeft = _mapController - .pointToLatLng(_customPointFromPoint(calculatedTopLeft)); - _coordsBottomRight = _mapController - .pointToLatLng(_customPointFromPoint(calculatedBottomRight)); - - setState(() {}); - } - - downloadProvider.region = downloadProvider.regionMode == RegionMode.circle - ? CircleRegion(_center!, _radius!) - : RectangleRegion( - LatLngBounds(_coordsTopLeft!, _coordsBottomRight!), - ); - } - - Future _countTiles() async { - final DownloadProvider provider = - Provider.of(context, listen: false); - - if (provider.region != null) { - provider - ..regionTiles = null - ..regionTiles = await FMTC.instance('').download.check( - provider.region!.toDownloadable( - provider.minZoom, - provider.maxZoom, - TileLayer(), - ), - ); - } - } -} - -CustomPoint _customPointFromPoint(Point point) => - CustomPoint(point.x, point.y); diff --git a/example/lib/screens/main/pages/downloader/components/min_max_zoom_controller_popup.dart b/example/lib/screens/main/pages/downloader/components/min_max_zoom_controller_popup.dart deleted file mode 100644 index 6623f006..00000000 --- a/example/lib/screens/main/pages/downloader/components/min_max_zoom_controller_popup.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../shared/state/download_provider.dart'; - -class MinMaxZoomControllerPopup extends StatelessWidget { - const MinMaxZoomControllerPopup({ - super.key, - }); - - @override - Widget build(BuildContext context) => Padding( - padding: EdgeInsets.only( - top: 12, - left: 12, - right: 12, - bottom: 12 + MediaQuery.of(context).viewInsets.bottom, - ), - child: Consumer( - child: Text( - 'Change Min/Max Zoom Levels', - style: GoogleFonts.openSans( - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - builder: (context, provider, child) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - child!, - const SizedBox(height: 10), - TextFormField( - decoration: const InputDecoration( - prefixIcon: Icon(Icons.zoom_out), - label: Text('Minimum Zoom Level'), - ), - validator: (input) { - if (input == null || input.isEmpty) return 'Required'; - if (int.parse(input) < 1) return 'Must be 1 or more'; - if (int.parse(input) > provider.maxZoom) { - return 'Must be less than maximum zoom'; - } - - return null; - }, - onChanged: (input) { - if (input.isNotEmpty) provider.minZoom = int.parse(input); - }, - keyboardType: TextInputType.number, - autovalidateMode: AutovalidateMode.onUserInteraction, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - _NumericalRangeFormatter(min: 1, max: 22), - ], - textInputAction: TextInputAction.next, - initialValue: provider.minZoom.toString(), - ), - const SizedBox(height: 5), - TextFormField( - decoration: const InputDecoration( - prefixIcon: Icon(Icons.zoom_in), - label: Text('Maximum Zoom Level'), - ), - validator: (input) { - if (input == null || input.isEmpty) return 'Required'; - if (int.parse(input) > 22) return 'Must be 22 or less'; - if (int.parse(input) < provider.minZoom) { - return 'Must be more than minimum zoom'; - } - - return null; - }, - onChanged: (input) { - if (input.isNotEmpty) provider.maxZoom = int.parse(input); - }, - keyboardType: TextInputType.number, - autovalidateMode: AutovalidateMode.onUserInteraction, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - _NumericalRangeFormatter(min: 1, max: 22), - ], - textInputAction: TextInputAction.done, - initialValue: provider.maxZoom.toString(), - ), - ], - ), - ), - ); -} - -class _NumericalRangeFormatter extends TextInputFormatter { - final int min; - final int max; - - _NumericalRangeFormatter({ - required this.min, - required this.max, - }); - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) => - newValue.text == '' - ? newValue - : int.parse(newValue.text) < min - ? TextEditingValue.empty.copyWith(text: min.toString()) - : int.parse(newValue.text) > max - ? TextEditingValue.empty.copyWith(text: max.toString()) - : newValue; -} diff --git a/example/lib/screens/main/pages/downloader/components/shape_controller_popup.dart b/example/lib/screens/main/pages/downloader/components/shape_controller_popup.dart deleted file mode 100644 index b17d0f9c..00000000 --- a/example/lib/screens/main/pages/downloader/components/shape_controller_popup.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../shared/state/download_provider.dart'; -import '../../../../../shared/vars/region_mode.dart'; - -class ShapeControllerPopup extends StatelessWidget { - const ShapeControllerPopup({super.key}); - - static const Map> regionShapes = { - 'Square': [ - Icons.crop_square_sharp, - RegionMode.square, - ], - 'Rectangle (Vertical)': [ - Icons.crop_portrait_sharp, - RegionMode.rectangleVertical, - ], - 'Rectangle (Horizontal)': [ - Icons.crop_landscape_sharp, - RegionMode.rectangleHorizontal, - ], - 'Circle': [ - Icons.circle_outlined, - RegionMode.circle, - ], - 'Line/Path': [ - Icons.timeline, - null, - ], - }; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.all(12), - child: Consumer( - builder: (context, provider, _) => ListView.separated( - itemCount: regionShapes.length, - shrinkWrap: true, - itemBuilder: (context, i) { - final String key = regionShapes.keys.toList()[i]; - final IconData icon = regionShapes.values.toList()[i][0]; - final RegionMode? mode = regionShapes.values.toList()[i][1]; - - return ListTile( - visualDensity: VisualDensity.compact, - title: Text(key), - subtitle: i == regionShapes.length - 1 - ? const Text('Disabled in example application') - : null, - leading: Icon(icon), - trailing: - provider.regionMode == mode ? const Icon(Icons.done) : null, - onTap: i != regionShapes.length - 1 - ? () { - provider.regionMode = mode!; - Navigator.of(context).pop(); - } - : null, - enabled: i != regionShapes.length - 1, - ); - }, - separatorBuilder: (context, i) => - i == regionShapes.length - 2 ? const Divider() : Container(), - ), - ), - ); -} diff --git a/example/lib/screens/main/pages/downloader/downloader.dart b/example/lib/screens/main/pages/downloader/downloader.dart deleted file mode 100644 index 6e133fb3..00000000 --- a/example/lib/screens/main/pages/downloader/downloader.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../../shared/state/download_provider.dart'; -import '../../../download_region/download_region.dart'; -import 'components/header.dart'; -import 'components/map_view.dart'; - -class DownloaderPage extends StatefulWidget { - const DownloaderPage({super.key}); - - @override - State createState() => _DownloaderPageState(); -} - -class _DownloaderPageState extends State { - @override - Widget build(BuildContext context) => Scaffold( - body: Column( - children: [ - const SafeArea( - child: Padding( - padding: EdgeInsets.all(12), - child: Header(), - ), - ), - Expanded( - child: SizedBox.expand( - child: ClipRRect( - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(20), - topRight: MediaQuery.of(context).size.width <= 950 - ? const Radius.circular(20) - : Radius.zero, - ), - child: const MapView(), - ), - ), - ), - ], - ), - floatingActionButton: Consumer( - builder: (context, provider, _) => FloatingActionButton.extended( - onPressed: provider.region == null || provider.regionTiles == null - ? () {} - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => - DownloadRegionPopup(region: provider.region!), - fullscreenDialog: true, - ), - ), - icon: const Icon(Icons.arrow_forward), - label: Padding( - padding: const EdgeInsets.only(left: 10), - child: provider.regionTiles == null - ? SizedBox( - height: 36, - width: 36, - child: Center( - child: SizedBox( - height: 28, - width: 28, - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.secondary, - ), - ), - ), - ) - : Text('${provider.regionTiles} tiles'), - ), - ), - ), - ); -} diff --git a/example/lib/screens/main/pages/downloading/components/download_layout.dart b/example/lib/screens/main/pages/downloading/components/download_layout.dart new file mode 100644 index 00000000..70d726ca --- /dev/null +++ b/example/lib/screens/main/pages/downloading/components/download_layout.dart @@ -0,0 +1,268 @@ +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/header.dart b/example/lib/screens/main/pages/downloading/components/header.dart deleted file mode 100644 index 64d35a98..00000000 --- a/example/lib/screens/main/pages/downloading/components/header.dart +++ /dev/null @@ -1,60 +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/state/download_provider.dart'; - -class Header extends StatefulWidget { - const Header({ - super.key, - }); - - @override - State
createState() => _HeaderState(); -} - -class _HeaderState extends State
{ - bool cancelled = false; - - @override - Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Downloading', - style: GoogleFonts.openSans( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - Text( - 'Downloading To: ${provider.selectedStore?.storeName ?? ''}', - overflow: TextOverflow.fade, - softWrap: false, - ), - ], - ), - ), - const SizedBox(width: 15), - IconButton( - icon: const Icon(Icons.cancel), - tooltip: 'Cancel Download', - onPressed: cancelled - ? null - : () async { - await FMTC - .instance(provider.selectedStore!.storeName) - .download - .cancel(); - setState(() => cancelled = true); - }, - ), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/downloading/components/horizontal_layout.dart b/example/lib/screens/main/pages/downloading/components/horizontal_layout.dart deleted file mode 100644 index 98a8b23d..00000000 --- a/example/lib/screens/main/pages/downloading/components/horizontal_layout.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../../../../shared/vars/size_formatter.dart'; -import 'stat_display.dart'; -import 'tile_image.dart'; - -class HorizontalLayout extends StatelessWidget { - const HorizontalLayout({ - super.key, - required this.data, - }); - - final DownloadProgress data; - - @override - Widget build(BuildContext context) => Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - tileImage(data: data), - const SizedBox(width: 15), - Column( - children: [ - StatDisplay( - statistic: data.bufferMode == DownloadBufferMode.disabled - ? data.successfulTiles.toString() - : '${data.successfulTiles} (${data.successfulTiles - data.persistedTiles})', - description: 'successful tiles', - ), - const SizedBox(height: 5), - StatDisplay( - statistic: data.bufferMode == DownloadBufferMode.disabled - ? (data.successfulSize * 1024).asReadableSize - : '${(data.successfulSize * 1024).asReadableSize} (${((data.successfulSize - data.persistedSize) * 1024).asReadableSize})', - description: 'successful size', - ), - const SizedBox(height: 5), - StatDisplay( - statistic: data.maxTiles.toString(), - description: 'total tiles', - ), - ], - ), - const SizedBox(width: 30), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - StatDisplay( - statistic: data.averageTPS.toStringAsFixed(2), - description: 'average tps', - ), - const SizedBox(height: 5), - StatDisplay( - statistic: data.duration - .toString() - .split('.') - .first - .padLeft(8, '0'), - description: 'duration taken', - ), - const SizedBox(height: 5), - StatDisplay( - statistic: data.estRemainingDuration - .toString() - .split('.') - .first - .padLeft(8, '0'), - description: 'est remaining duration', - ), - ], - ), - const SizedBox(width: 30), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - StatDisplay( - statistic: - '${data.existingTiles} (${data.existingTilesDiscount.ceil()}%)', - description: 'existing tiles', - ), - const SizedBox(height: 5), - StatDisplay( - statistic: - '${data.seaTiles} (${data.seaTilesDiscount.ceil()}%)', - description: 'sea tiles', - ), - ], - ), - ], - ), - const SizedBox(height: 30), - Stack( - children: [ - LinearProgressIndicator( - value: data.percentageProgress / 100, - minHeight: 12, - backgroundColor: Colors.grey[300], - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary.withOpacity(0.5), - ), - ), - LinearProgressIndicator( - value: data.persistedTiles / data.maxTiles, - minHeight: 12, - backgroundColor: Colors.transparent, - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - const SizedBox(height: 30), - Expanded( - child: data.failedTiles.isEmpty - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.report_off, size: 36), - SizedBox(height: 10), - Text('No Failed Tiles'), - ], - ) - : Row( - children: [ - const SizedBox(width: 30), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.warning, - size: 36, - ), - const SizedBox(height: 10), - StatDisplay( - statistic: data.failedTiles.length.toString(), - description: 'failed tiles', - ), - ], - ), - const SizedBox(width: 30), - Expanded( - child: ListView.builder( - itemCount: data.failedTiles.length, - itemBuilder: (context, index) => ListTile( - title: Text( - data.failedTiles[index], - textAlign: TextAlign.end, - ), - ), - ), - ), - ], - ), - ), - ], - ); -} diff --git a/example/lib/screens/main/pages/downloading/components/main_statistics.dart b/example/lib/screens/main/pages/downloading/components/main_statistics.dart new file mode 100644 index 00000000..1e8c8bf6 --- /dev/null +++ b/example/lib/screens/main/pages/downloading/components/main_statistics.dart @@ -0,0 +1,181 @@ +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 new file mode 100644 index 00000000..1881fc65 --- /dev/null +++ b/example/lib/screens/main/pages/downloading/components/multi_linear_progress_indicator.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +typedef IndividualProgress = ({num value, Color color, Widget? child}); + +class MulitLinearProgressIndicator extends StatefulWidget { + const MulitLinearProgressIndicator({ + super.key, + required this.progresses, + this.maxValue = 1, + this.backgroundChild, + this.height = 24, + this.radius, + this.childAlignment = Alignment.centerRight, + this.animationDuration = const Duration(milliseconds: 500), + }); + + final List progresses; + final num maxValue; + final Widget? backgroundChild; + final double height; + final BorderRadiusGeometry? radius; + final AlignmentGeometry childAlignment; + final Duration animationDuration; + + @override + State createState() => + _MulitLinearProgressIndicatorState(); +} + +class _MulitLinearProgressIndicatorState + extends State { + @override + Widget build(BuildContext context) => RepaintBoundary( + child: LayoutBuilder( + builder: (context, constraints) => ClipRRect( + borderRadius: + widget.radius ?? BorderRadius.circular(widget.height / 2), + child: SizedBox( + height: widget.height, + width: constraints.maxWidth, + child: Stack( + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: widget.radius ?? + BorderRadius.circular(widget.height / 2), + border: Border.all( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + padding: EdgeInsets.symmetric( + vertical: 2, + horizontal: widget.height / 2, + ), + alignment: widget.childAlignment, + child: widget.backgroundChild, + ), + ), + ...widget.progresses.map( + (e) => AnimatedPositioned( + height: widget.height, + left: 0, + width: (constraints.maxWidth / widget.maxValue) * e.value, + duration: widget.animationDuration, + child: Container( + decoration: BoxDecoration( + color: e.color, + borderRadius: widget.radius ?? + BorderRadius.circular(widget.height / 2), + ), + padding: EdgeInsets.symmetric( + vertical: 2, + horizontal: widget.height / 2, + ), + alignment: widget.childAlignment, + child: e.child, + ), + ), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/example/lib/screens/main/pages/downloading/components/stat_display.dart b/example/lib/screens/main/pages/downloading/components/stat_display.dart index 998f8263..3592c850 100644 --- a/example/lib/screens/main/pages/downloading/components/stat_display.dart +++ b/example/lib/screens/main/pages/downloading/components/stat_display.dart @@ -11,19 +11,23 @@ class StatDisplay extends StatelessWidget { final String description; @override - Widget build(BuildContext context) => Column( - children: [ - Text( - statistic, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, + 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), - ), - ], + 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 new file mode 100644 index 00000000..7c312023 --- /dev/null +++ b/example/lib/screens/main/pages/downloading/components/stats_table.dart @@ -0,0 +1,83 @@ +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/components/tile_image.dart b/example/lib/screens/main/pages/downloading/components/tile_image.dart deleted file mode 100644 index d60da7cc..00000000 --- a/example/lib/screens/main/pages/downloading/components/tile_image.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -Widget tileImage({ - required DownloadProgress data, - double tileImageSize = 256 / 1.25, -}) => - data.tileImage != null - ? Stack( - alignment: Alignment.center, - children: [ - Container( - foregroundDecoration: BoxDecoration( - color: data.percentageProgress != 100 - ? null - : Colors.white.withOpacity(0.75), - ), - child: Image( - image: data.tileImage!, - height: tileImageSize, - width: tileImageSize, - gaplessPlayback: true, - ), - ), - if (data.percentageProgress == 100) - Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon( - Icons.done_all, - size: 36, - color: Colors.green, - ), - SizedBox(height: 10), - Text('Download Complete'), - ], - ), - ], - ) - : SizedBox( - height: tileImageSize, - width: tileImageSize, - child: const Center( - child: CircularProgressIndicator(), - ), - ); diff --git a/example/lib/screens/main/pages/downloading/components/vertical_layout.dart b/example/lib/screens/main/pages/downloading/components/vertical_layout.dart deleted file mode 100644 index 72170247..00000000 --- a/example/lib/screens/main/pages/downloading/components/vertical_layout.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../../../../../shared/vars/size_formatter.dart'; -import 'stat_display.dart'; - -class VerticalLayout extends StatelessWidget { - const VerticalLayout({ - super.key, - required this.data, - }); - - final DownloadProgress data; - - @override - Widget build(BuildContext context) => Column( - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - StatDisplay( - statistic: - '${data.successfulTiles} / ${data.maxTiles} (${data.averageTPS.round()} avg tps)', - description: 'successful / total tiles', - ), - const SizedBox(height: 2), - StatDisplay( - statistic: (data.successfulSize * 1024).asReadableSize, - description: 'downloaded size', - ), - const SizedBox(height: 2), - StatDisplay( - statistic: - data.duration.toString().split('.').first.padLeft(8, '0'), - description: 'duration taken', - ), - const SizedBox(height: 2), - StatDisplay( - statistic: data.estRemainingDuration - .toString() - .split('.') - .first - .padLeft(8, '0'), - description: 'est remaining duration', - ), - const SizedBox(height: 2), - StatDisplay( - statistic: data.estTotalDuration - .toString() - .split('.') - .first - .padLeft(8, '0'), - description: 'est total duration', - ), - const SizedBox(height: 2), - StatDisplay( - statistic: - '${data.existingTiles} (${data.existingTilesDiscount.ceil()}%) | ${data.seaTiles} (${data.seaTilesDiscount.ceil()}%)', - description: 'existing tiles | sea tiles', - ), - ], - ), - const SizedBox(height: 15), - Stack( - children: [ - LinearProgressIndicator( - value: data.percentageProgress / 100, - minHeight: 8, - backgroundColor: Colors.grey[300], - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary.withOpacity(0.5), - ), - ), - LinearProgressIndicator( - value: data.persistedTiles / data.maxTiles, - minHeight: 8, - backgroundColor: Colors.transparent, - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - const SizedBox(height: 15), - Expanded( - child: data.failedTiles.isEmpty - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.report_off, size: 36), - SizedBox(height: 10), - Text('No Failed Tiles'), - ], - ) - : Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - data.failedTiles.length.toString(), - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 10), - const Text( - 'failed tiles', - style: TextStyle(fontSize: 16), - ), - ], - ), - const SizedBox(height: 15), - Expanded( - child: ListView.builder( - itemCount: data.failedTiles.length, - itemBuilder: (context, index) => ListTile( - title: Text(data.failedTiles[index]), - ), - ), - ), - ], - ), - ), - ], - ); -} diff --git a/example/lib/screens/main/pages/downloading/downloading.dart b/example/lib/screens/main/pages/downloading/downloading.dart index 7a2c668e..f113d800 100644 --- a/example/lib/screens/main/pages/downloading/downloading.dart +++ b/example/lib/screens/main/pages/downloading/downloading.dart @@ -1,63 +1,132 @@ +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 '../../../../shared/state/download_provider.dart'; -import 'components/header.dart'; -import 'components/horizontal_layout.dart'; -import 'components/vertical_layout.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}); + const DownloadingPage({super.key, required this.moveToMapPage}); + + final void Function() moveToMapPage; @override State createState() => _DownloadingPageState(); } -class _DownloadingPageState extends State { +class _DownloadingPageState extends State + with AutomaticKeepAliveClientMixin { + StreamSubscription? downloadProgressStreamSubscription; + @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: Padding( - padding: const EdgeInsets.all(6), - child: Consumer( - builder: (context, provider, _) => - StreamBuilder( - stream: provider.downloadProgress, - initialData: DownloadProgress.empty(), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.done) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => provider.setDownloadProgress( - null, - notify: false, - ), - ); - } - - return LayoutBuilder( - builder: (context, constraints) => - constraints.maxWidth > 725 - ? HorizontalLayout(data: snapshot.data!) - : VerticalLayout(data: snapshot.data!), - ); - }, + 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 new file mode 100644 index 00000000..32009d60 --- /dev/null +++ b/example/lib/screens/main/pages/downloading/state/downloading_provider.dart @@ -0,0 +1,98 @@ +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 new file mode 100644 index 00000000..0491b3c7 --- /dev/null +++ b/example/lib/screens/main/pages/map/components/bubble_arrow_painter.dart @@ -0,0 +1,38 @@ +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 new file mode 100644 index 00000000..33d0a314 --- /dev/null +++ b/example/lib/screens/main/pages/map/components/download_progress_indicator.dart @@ -0,0 +1,99 @@ +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 new file mode 100644 index 00000000..9bc23269 --- /dev/null +++ b/example/lib/screens/main/pages/map/components/empty_tile_provider.dart @@ -0,0 +1,11 @@ +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 new file mode 100644 index 00000000..fb7b5628 --- /dev/null +++ b/example/lib/screens/main/pages/map/components/map_view.dart @@ -0,0 +1,104 @@ +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 new file mode 100644 index 00000000..6ac0c028 --- /dev/null +++ b/example/lib/screens/main/pages/map/components/quit_tiles_preview_indicator.dart @@ -0,0 +1,72 @@ +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 new file mode 100644 index 00000000..399b0857 --- /dev/null +++ b/example/lib/screens/main/pages/map/components/side_indicator_painter.dart @@ -0,0 +1,52 @@ +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 new file mode 100644 index 00000000..94114889 --- /dev/null +++ b/example/lib/screens/main/pages/map/map_page.dart @@ -0,0 +1,48 @@ +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/map_view.dart b/example/lib/screens/main/pages/map/map_view.dart deleted file mode 100644 index bb281017..00000000 --- a/example/lib/screens/main/pages/map/map_view.dart +++ /dev/null @@ -1,92 +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/components/loading_indicator.dart'; -import '../../../../shared/state/general_provider.dart'; -import 'build_attribution.dart'; - -class MapPage extends StatefulWidget { - const MapPage({ - super.key, - }); - - @override - State createState() => _MapPageState(); -} - -class _MapPageState extends State { - @override - Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => FutureBuilder?>( - future: provider.currentStore == null - ? Future.sync(() => {}) - : FMTC.instance(provider.currentStore!).metadata.readAsync, - builder: (context, metadata) { - if (!metadata.hasData || - metadata.data == null || - (provider.currentStore != null && metadata.data!.isEmpty)) { - return const LoadingIndicator( - message: - 'Loading Settings...\n\nSeeing this screen for a long time?\nThere may be a misconfiguration of the\nstore. Try disabling caching and deleting\n faulty stores.', - ); - } - - final String urlTemplate = - provider.currentStore != null && metadata.data != null - ? metadata.data!['sourceURL']! - : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; - - return FlutterMap( - options: MapOptions( - center: LatLng(51.509364, -0.128928), - zoom: 9.2, - maxZoom: 22, - maxBounds: LatLngBounds.fromPoints([ - LatLng(51.50440309992153, -0.7140577160848564), - LatLng(51.50359743451854, -0.5780629452415917), - LatLng(51.536279957688045, -0.5727737156917649), - LatLng(-51.53318462807709, -0.7227703206129361), - ]), - interactiveFlags: InteractiveFlag.all & ~InteractiveFlag.rotate, - scrollWheelVelocity: 0.002, - keepAlive: true, - ), - nonRotatedChildren: buildStdAttribution(urlTemplate), - children: [ - TileLayer( - urlTemplate: urlTemplate, - tileProvider: provider.currentStore != null - ? FMTC.instance(provider.currentStore!).getTileProvider( - 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']!, - ), - ), - ) - : NetworkNoRetryTileProvider(), - maxZoom: 22, - userAgentPackageName: 'dev.org.fmtc.example.app', - panBuffer: 3, - backgroundColor: const Color(0xFFaad3df), - ), - ], - ); - }, - ), - ); -} diff --git a/example/lib/screens/main/pages/map/state/map_provider.dart b/example/lib/screens/main/pages/map/state/map_provider.dart new file mode 100644 index 00000000..0228b98e --- /dev/null +++ b/example/lib/screens/main/pages/map/state/map_provider.dart @@ -0,0 +1,28 @@ +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 index a72071d7..245ec722 100644 --- a/example/lib/screens/main/pages/recovery/components/empty_indicator.dart +++ b/example/lib/screens/main/pages/recovery/components/empty_indicator.dart @@ -6,10 +6,10 @@ class EmptyIndicator extends StatelessWidget { }); @override - Widget build(BuildContext context) => Center( + Widget build(BuildContext context) => const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: const [ + 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 index d1a37e7e..a5bd75cf 100644 --- a/example/lib/screens/main/pages/recovery/components/header.dart +++ b/example/lib/screens/main/pages/recovery/components/header.dart @@ -9,7 +9,7 @@ class Header extends StatelessWidget { @override Widget build(BuildContext context) => Text( 'Recovery', - style: GoogleFonts.openSans( + 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 index e8950982..4c310fbc 100644 --- a/example/lib/screens/main/pages/recovery/components/recovery_list.dart +++ b/example/lib/screens/main/pages/recovery/components/recovery_list.dart @@ -11,7 +11,7 @@ class RecoveryList extends StatefulWidget { required this.moveToDownloadPage, }); - final List all; + final Iterable<({bool isFailed, RecoveredRegion region})> all; final void Function() moveToDownloadPage; @override @@ -20,27 +20,25 @@ class RecoveryList extends StatefulWidget { class _RecoveryListState extends State { @override - Widget build(BuildContext context) => ListView.builder( + Widget build(BuildContext context) => ListView.separated( itemCount: widget.all.length, itemBuilder: (context, index) { - final region = widget.all[index]; + final result = widget.all.elementAt(index); + final region = result.region; + final isFailed = result.isFailed; + return ListTile( - leading: FutureBuilder( - future: FMTC.instance.rootDirectory.recovery - .getFailedRegion(region.id), - builder: (context, isFailed) => Icon( - isFailed.data != null - ? Icons.warning - : region.type == RegionType.circle - ? Icons.circle_outlined - : region.type == RegionType.line - ? Icons.timeline - : Icons.rectangle_outlined, - color: isFailed.data != null ? Colors.red : null, - ), + leading: Icon( + isFailed ? Icons.warning : Icons.pending_actions, + color: isFailed ? Colors.red : null, ), title: Text( - '${region.storeName} - ${region.type.name[0].toUpperCase() + region.type.name.substring(1)} Type', + '${region.storeName} - ${switch (region.toRegion()) { + RectangleRegion() => 'Rectangle', + CircleRegion() => 'Circle', + LineRegion() => 'Line', + CustomPolygonRegion() => 'Custom Polygon', + }} Type', ), subtitle: FutureBuilder( future: Nominatim.reverseSearch( @@ -54,35 +52,34 @@ class _RecoveryListState extends State { addressDetails: true, ), builder: (context, response) => Text( - 'Started at ${region.time} (~${DateTime.now().difference(region.time).inMinutes} minutes ago)\n${response.hasData ? 'Center near ${response.data!.address!['postcode']}, ${response.data!.address!['country']}' : response.hasError ? 'Unable To Reverse Geocode Location' : 'Please Wait...'}', + '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...'}', ), ), - onTap: () {}, + isThreeLine: true, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.delete_forever, color: Colors.red), onPressed: () async { - await FMTC.instance.rootDirectory.recovery - .cancel(region.id); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Deleted Recovery Information'), - ), - ); - } + 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, - region: region, + 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 index 8eaa58fd..b7045901 100644 --- a/example/lib/screens/main/pages/recovery/components/recovery_start_button.dart +++ b/example/lib/screens/main/pages/recovery/components/recovery_start_button.dart @@ -1,78 +1,52 @@ 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/download_provider.dart'; -import '../../../../download_region/download_region.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.region, + required this.result, }); final void Function() moveToDownloadPage; - final RecoveredRegion region; + final ({bool isFailed, RecoveredRegion region}) result; @override - Widget build(BuildContext context) => FutureBuilder( - future: FMTC.instance.rootDirectory.recovery.getFailedRegion(region.id), - builder: (context, isFailed) => FutureBuilder( - future: FMTC - .instance('') - .download - .check(region.toDownloadable(TileLayer())), - builder: (context, tiles) => tiles.hasData - ? IconButton( - icon: Icon( - Icons.download, - color: isFailed.data != null ? Colors.green : null, - ), - onPressed: isFailed.data == null - ? null - : () async { - final DownloadProvider downloadProvider = - Provider.of( - context, - listen: false, - ) - ..region = region - .toDownloadable(TileLayer()) - .originalRegion - ..minZoom = region.minZoom - ..maxZoom = region.maxZoom - ..preventRedownload = region.preventRedownload - ..seaTileRemoval = region.seaTileRemoval - ..setSelectedStore( - FMTC.instance(region.storeName), - ) - ..regionTiles = tiles.data; - - await Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => - DownloadRegionPopup( - region: downloadProvider.region!, - ), - fullscreenDialog: true, - ), - ); + 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), + ); - moveToDownloadPage(); - }, - ) - : const Padding( - padding: EdgeInsets.all(8), - child: SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - strokeWidth: 3, + 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 index 31a09a8e..81b97607 100644 --- a/example/lib/screens/main/pages/recovery/recovery.dart +++ b/example/lib/screens/main/pages/recovery/recovery.dart @@ -19,19 +19,18 @@ class RecoveryPage extends StatefulWidget { } class _RecoveryPageState extends State { - late Future> _recoverableRegions; + late Future> + _recoverableRegions; @override void initState() { super.initState(); - void listRecoverableRegions() => _recoverableRegions = - FMTC.instance.rootDirectory.recovery.recoverableRegions; + void listRecoverableRegions() => + _recoverableRegions = FMTCRoot.recovery.recoverableRegions; listRecoverableRegions(); - FMTC.instance.rootDirectory.stats - .watchChanges(watchRecovery: true) - .listen((_) { + FMTCRoot.stats.watchRecovery().listen((_) { if (mounted) { listRecoverableRegions(); setState(() {}); @@ -50,7 +49,7 @@ class _RecoveryPageState extends State { const Header(), const SizedBox(height: 12), Expanded( - child: FutureBuilder>( + child: FutureBuilder( future: _recoverableRegions, builder: (context, all) => all.hasData ? all.data!.isEmpty @@ -60,7 +59,7 @@ class _RecoveryPageState extends State { moveToDownloadPage: widget.moveToDownloadPage, ) : const LoadingIndicator( - message: 'Loading Recoverable Downloads...', + 'Retrieving Recoverable Downloads', ), ), ), diff --git a/example/lib/screens/main/pages/downloader/components/crosshairs.dart b/example/lib/screens/main/pages/region_selection/components/crosshairs.dart similarity index 96% rename from example/lib/screens/main/pages/downloader/components/crosshairs.dart rename to example/lib/screens/main/pages/region_selection/components/crosshairs.dart index 67be9251..943a3a1f 100644 --- a/example/lib/screens/main/pages/downloader/components/crosshairs.dart +++ b/example/lib/screens/main/pages/region_selection/components/crosshairs.dart @@ -4,7 +4,7 @@ class Crosshairs extends StatelessWidget { const Crosshairs({ super.key, this.size = 20, - this.thickness = 2, + this.thickness = 3, }); final double size; 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 new file mode 100644 index 00000000..cffb227a --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/custom_polygon_snapping_indicator.dart @@ -0,0 +1,41 @@ +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 new file mode 100644 index 00000000..0caef237 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/region_shape.dart @@ -0,0 +1,99 @@ +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], + isFilled: true, + 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 new file mode 100644 index 00000000..87033919 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/additional_pane.dart @@ -0,0 +1,38 @@ +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 new file mode 100644 index 00000000..c7458ea7 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/adjust_zoom_lvls_pane.dart @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..894773c1 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/line_region_pane.dart @@ -0,0 +1,101 @@ +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 new file mode 100644 index 00000000..3e8c1af8 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/side_panel/additional_panes/slider_panel_base.dart @@ -0,0 +1,55 @@ +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 new file mode 100644 index 00000000..e8f54d21 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/side_panel/custom_slider_track_shape.dart @@ -0,0 +1,19 @@ +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 new file mode 100644 index 00000000..6024fe60 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/side_panel/parent.dart @@ -0,0 +1,62 @@ +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 new file mode 100644 index 00000000..1c0fd3e5 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/side_panel/primary_pane.dart @@ -0,0 +1,183 @@ +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 new file mode 100644 index 00000000..7c9763f5 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/side_panel/region_shape_button.dart @@ -0,0 +1,28 @@ +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 new file mode 100644 index 00000000..e3e90476 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/components/usage_instructions.dart @@ -0,0 +1,109 @@ +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 new file mode 100644 index 00000000..9e4ad313 --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/region_selection.dart @@ -0,0 +1,301 @@ +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 new file mode 100644 index 00000000..fac3ec8d --- /dev/null +++ b/example/lib/screens/main/pages/region_selection/state/region_selection_provider.dart @@ -0,0 +1,130 @@ +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/settingsAndAbout/components/header.dart b/example/lib/screens/main/pages/settingsAndAbout/components/header.dart deleted file mode 100644 index b3efb980..00000000 --- a/example/lib/screens/main/pages/settingsAndAbout/components/header.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -class Header extends StatelessWidget { - const Header({ - super.key, - required this.title, - }); - - final String title; - - @override - Widget build(BuildContext context) => Text( - title, - style: GoogleFonts.openSans( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ); -} diff --git a/example/lib/screens/main/pages/settingsAndAbout/settings_and_about.dart b/example/lib/screens/main/pages/settingsAndAbout/settings_and_about.dart deleted file mode 100644 index 0b5693d0..00000000 --- a/example/lib/screens/main/pages/settingsAndAbout/settings_and_about.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import '../../../../shared/components/loading_indicator.dart'; -import 'components/header.dart'; - -class SettingsAndAboutPage extends StatefulWidget { - const SettingsAndAboutPage({super.key}); - - @override - State createState() => _SettingsAndAboutPageState(); -} - -class _SettingsAndAboutPageState extends State { - final creditsScrollController = ScrollController(); - final Map _settings = { - 'Reset FMTC On Every Startup\nDefaults to disabled': 'reset', - "Bypass Download Threads Limitation\nBy default, only 2 simultaneous bulk download threads can be used in the example application\nEnabling this increases the number to 10, which is only to be used in compliance with the tile server's TOS": - 'bypassDownloadThreadsLimitation', - }; - - @override - Widget build(BuildContext context) => Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Header( - title: 'Settings', - ), - const SizedBox(height: 12), - FutureBuilder( - future: SharedPreferences.getInstance(), - builder: (context, prefs) => prefs.hasData - ? ListView.builder( - itemCount: _settings.length, - shrinkWrap: true, - itemBuilder: (context, index) { - final List info = - _settings.keys.toList()[index].split('\n'); - - return SwitchListTile( - title: Text(info[0]), - subtitle: info.length >= 2 - ? Text( - info.getRange(1, info.length).join('\n'), - ) - : null, - onChanged: (newBool) async { - await prefs.data!.setBool( - _settings.values.toList()[index], - newBool, - ); - setState(() {}); - }, - value: prefs.data!.getBool( - _settings.values.toList()[index], - ) ?? - false, - ); - }, - ) - : const LoadingIndicator( - message: 'Loading Settings...', - ), - ), - const SizedBox(height: 24), - Row( - children: [ - const Header( - title: 'App Credits', - ), - const SizedBox(width: 12), - TextButton.icon( - onPressed: () { - showLicensePage( - context: context, - applicationName: 'FMTC Demo', - applicationVersion: - 'for v8.0.0\n(on ${Platform().operatingSystemFormatted})', - applicationIcon: Image.asset( - 'assets/icons/ProjectIcon.png', - height: 48, - ), - ); - }, - icon: const Icon(Icons.info), - label: const Text('Show Licenses'), - ), - ], - ), - const SizedBox(height: 12), - Expanded( - child: SingleChildScrollView( - controller: creditsScrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( - "An example application for the 'flutter_map_tile_caching' project, built by Luka S (JaffaKetchup). Tap on the above button to show more detailed information.\n", - ), - Text( - "Many thanks go to all my donors, whom can be found on the documentation website. If you want to support me, any amount is appriciated! Please visit the GitHub repository for donation/sponsorship options.\n\nYou can see all the dependenices used in this application by tapping the 'Show Licenses' button above. In addition to the packages listed there, thanks also go to:\n - Nominatim: their services are used to retrieve the location of a recoverable download on the 'Recover' screen\n - OpenStreetMap: their tiles are the default throughout the application\n - Inno Setup: their software provides the installer for the Windows version of this application", - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); -} - -extension on Platform { - String get operatingSystemFormatted { - switch (Platform.operatingSystem) { - case 'android': - return 'Android'; - case 'ios': - return 'iOS'; - case 'linux': - return 'Linux'; - case 'macos': - return 'MacOS'; - case 'windows': - return 'Windows'; - default: - return 'Unknown Operating System'; - } - } -} diff --git a/example/lib/screens/main/pages/stores/components/empty_indicator.dart b/example/lib/screens/main/pages/stores/components/empty_indicator.dart index 89a51da4..c15e7ad0 100644 --- a/example/lib/screens/main/pages/stores/components/empty_indicator.dart +++ b/example/lib/screens/main/pages/stores/components/empty_indicator.dart @@ -6,13 +6,13 @@ class EmptyIndicator extends StatelessWidget { }); @override - Widget build(BuildContext context) => Center( + Widget build(BuildContext context) => const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: const [ + children: [ Icon(Icons.folder_off, size: 36), SizedBox(height: 10), - Text('No Stores Found'), + 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 index 4e7b8146..cb1877bc 100644 --- a/example/lib/screens/main/pages/stores/components/header.dart +++ b/example/lib/screens/main/pages/stores/components/header.dart @@ -18,7 +18,7 @@ class Header extends StatelessWidget { children: [ Text( 'Stores', - style: GoogleFonts.openSans( + style: GoogleFonts.ubuntu( fontWeight: FontWeight.bold, fontSize: 24, ), 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 new file mode 100644 index 00000000..209ba452 --- /dev/null +++ b/example/lib/screens/main/pages/stores/components/root_stats_pane.dart @@ -0,0 +1,104 @@ +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 index cad14c5f..3a6b5941 100644 --- a/example/lib/screens/main/pages/stores/components/stat_display.dart +++ b/example/lib/screens/main/pages/stores/components/stat_display.dart @@ -13,15 +13,16 @@ class StatDisplay extends StatelessWidget { @override Widget build(BuildContext context) => Column( children: [ - statistic == null - ? const CircularProgressIndicator() - : Text( - statistic!, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), + if (statistic == null) + const CircularProgressIndicator.adaptive() + else + Text( + statistic!, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), Text( description, style: const TextStyle( diff --git a/example/lib/screens/main/pages/stores/components/store_tile.dart b/example/lib/screens/main/pages/stores/components/store_tile.dart index 2cc5b9d3..83a88365 100644 --- a/example/lib/screens/main/pages/stores/components/store_tile.dart +++ b/example/lib/screens/main/pages/stores/components/store_tile.dart @@ -1,21 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:fmtc_plus_sharing/fmtc_plus_sharing.dart'; import 'package:provider/provider.dart'; +import '../../../../../shared/misc/exts/size_formatter.dart'; import '../../../../../shared/state/general_provider.dart'; -import '../../../../../shared/vars/size_formatter.dart'; import '../../../../store_editor/store_editor.dart'; import 'stat_display.dart'; class StoreTile extends StatefulWidget { - const StoreTile({ - super.key, - required this.context, + StoreTile({ required this.storeName, - }); + }) : super(key: ValueKey(storeName)); - final BuildContext context; final String storeName; @override @@ -23,111 +19,15 @@ class StoreTile extends StatefulWidget { } class _StoreTileState extends State { - Future? _tiles; - Future? _size; - Future? _cacheHits; - Future? _cacheMisses; - Future? _image; - bool _deletingProgress = false; bool _emptyingProgress = false; - bool _exportingProgress = false; - - late final _store = FMTC.instance(widget.storeName); - - void _loadStatistics() { - _tiles = _store.stats.storeLengthAsync.then((l) => l.toString()); - _size = _store.stats.storeSizeAsync.then((s) => (s * 1024).asReadableSize); - _cacheHits = _store.stats.cacheHitsAsync.then((h) => h.toString()); - _cacheMisses = _store.stats.cacheMissesAsync.then((m) => m.toString()); - _image = _store.manage.tileImageAsync(size: 125); - - setState(() {}); - } - - List> get stats => [ - FutureBuilder( - future: _tiles, - builder: (context, snapshot) => StatDisplay( - statistic: snapshot.connectionState != ConnectionState.done - ? null - : snapshot.data, - description: 'Total Tiles', - ), - ), - FutureBuilder( - future: _size, - builder: (context, snapshot) => StatDisplay( - statistic: snapshot.connectionState != ConnectionState.done - ? null - : snapshot.data, - description: 'Total Size', - ), - ), - FutureBuilder( - future: _cacheHits, - builder: (context, snapshot) => StatDisplay( - statistic: snapshot.connectionState != ConnectionState.done - ? null - : snapshot.data, - description: 'Cache Hits', - ), - ), - FutureBuilder( - future: _cacheMisses, - builder: (context, snapshot) => StatDisplay( - statistic: snapshot.connectionState != ConnectionState.done - ? null - : snapshot.data, - description: 'Cache Misses', - ), - ), - ]; - - IconButton deleteStoreButton({required bool isCurrentStore}) => 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 _store.manage.delete(); - }, - ); @override - Widget build(BuildContext context) => Consumer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - FutureBuilder( - future: _image, - builder: (context, snapshot) => snapshot.data == null - ? const SizedBox( - height: 125, - width: 125, - child: Icon(Icons.help_outline, size: 36), - ) - : snapshot.data!, - ), - if (MediaQuery.of(context).size.width > 675) - ...stats - else - Column(children: stats), - ], - ), - builder: (context, provider, statistics) { - final bool isCurrentStore = provider.currentStore == widget.storeName; + 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( @@ -135,152 +35,229 @@ class _StoreTileState extends State { style: TextStyle( fontWeight: isCurrentStore ? FontWeight.bold : FontWeight.normal, - color: _store.manage.ready == false ? Colors.red : null, ), ), subtitle: _deletingProgress ? const Text('Deleting...') : null, - leading: _store.manage.ready == false - ? const Icon( - Icons.error, - color: Colors.red, - ) - : null, - onExpansionChanged: (e) { - if (e) _loadStatistics(); - }, + initiallyExpanded: isCurrentStore, children: [ SizedBox( width: double.infinity, child: Padding( - padding: const EdgeInsets.only(left: 18, bottom: 10), - child: _store.manage.ready - ? Column( - children: [ - statistics!, - const SizedBox(height: 15), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - deleteStoreButton( - isCurrentStore: isCurrentStore, - ), - IconButton( - icon: _emptyingProgress - ? const CircularProgressIndicator( - strokeWidth: 3, - ) - : const Icon(Icons.delete), - tooltip: 'Empty Store', - onPressed: _emptyingProgress - ? null - : () async { - setState( - () => _emptyingProgress = true, - ); - await _store.manage.resetAsync(); - setState( - () => _emptyingProgress = false, - ); + 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(), + ); + } - _loadStatistics(); - }, - ), - IconButton( - icon: _exportingProgress - ? const CircularProgressIndicator( - strokeWidth: 3, - ) - : const Icon(Icons.upload_file_rounded), - tooltip: 'Export Store', - onPressed: _exportingProgress - ? null - : () async { - setState( - () => _exportingProgress = true, - ); - final bool result = await _store - .export - .withGUI(context: context); - setState( - () => _exportingProgress = false, - ); - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - result - ? 'Exported Sucessfully' - : 'Export Cancelled', - ), - ), - ); - } - }, - ), - IconButton( - icon: const Icon(Icons.edit), - tooltip: 'Edit Store', - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => - StoreEditorPopup( - existingStoreName: widget.storeName, - isStoreInUse: isCurrentStore, + 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, ), - fullscreenDialog: true, + 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: const Icon(Icons.refresh), - tooltip: 'Force Refresh Statistics', - onPressed: _loadStatistics, - ), - IconButton( - icon: Icon( - Icons.done, - color: isCurrentStore ? Colors.green : null, - ), - tooltip: 'Use Store', - onPressed: isCurrentStore - ? null - : () { - provider - ..currentStore = widget.storeName - ..resetMap(); - }, + ), + IconButton( + icon: Icon( + Icons.done, + color: isCurrentStore ? Colors.green : null, ), - ], - ), - ], - ) - : Column( - children: [ - const SizedBox(height: 10), - Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.broken_image, size: 34), - Icon(Icons.error, size: 34), - ], - ), - const SizedBox(height: 8), - const Text( - 'Invalid Store', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, + tooltip: 'Use Store', + onPressed: isCurrentStore + ? null + : () { + context.read() + ..currentStore = widget.storeName + ..resetMap(); + }, ), - ), - const Text( - "This store's directory structure appears to have been corrupted. You must delete the store to resolve the issue.", - textAlign: TextAlign.center, - ), - const SizedBox(height: 5), - deleteStoreButton(isCurrentStore: isCurrentStore), - ], + ], + ), ), + ), + ], + ), ), ), ], diff --git a/example/lib/screens/main/pages/stores/stores.dart b/example/lib/screens/main/pages/stores/stores.dart index 9e7e3645..4b2024c0 100644 --- a/example/lib/screens/main/pages/stores/stores.dart +++ b/example/lib/screens/main/pages/stores/stores.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import '../../../../shared/components/loading_indicator.dart'; -import '../../../import_store/import_store.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 { @@ -17,90 +17,107 @@ class StoresPage extends StatefulWidget { } class _StoresPageState extends State { - late Future> _stores; + late final storesStream = FMTCRoot.stats + .watchStores(triggerImmediately: true) + .asyncMap((_) => FMTCRoot.stats.storesAvailable); @override - void initState() { - super.initState(); + Widget build(BuildContext context) { + const loadingIndicator = LoadingIndicator('Retrieving Stores'); - void listStores() => - _stores = FMTC.instance.rootDirectory.stats.storesAvailableAsync; + 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, + ); + } - listStores(); - FMTC.instance.rootDirectory.stats.watchChanges().listen((_) { - if (mounted) { - listStores(); - setState(() {}); - } - }); - } + if (snapshot.data!.isEmpty) { + return const Column( + children: [ + RootStatsPane(), + Expanded(child: EmptyIndicator()), + ], + ); + } - @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: _stores, - builder: (context, snapshot) => snapshot.hasError - ? throw snapshot.error! as FMTCDamagedStoreException - : snapshot.hasData - ? snapshot.data!.isEmpty - ? const EmptyIndicator() - : ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) => StoreTile( - context: context, - storeName: - snapshot.data![index].storeName, - key: ValueKey( - snapshot.data![index].storeName, - ), - ), - ) - : const LoadingIndicator( - message: 'Loading Stores...', - ), - ), + 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: SpeedDial( - icon: Icons.create_new_folder, - activeIcon: Icons.close, - children: [ - SpeedDialChild( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const StoreEditorPopup( - existingStoreName: null, - isStoreInUse: false, - ), - fullscreenDialog: true, - ), + ), + 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, ), - child: const Icon(Icons.add), - label: 'Create New Store', ), - SpeedDialChild( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const ImportStorePopup(), - 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, ), - child: const Icon(Icons.file_open), - label: 'Import Stores', ), - ], - ), - ); + ), + ], + ), + ); + } } diff --git a/example/lib/screens/main/pages/update/components/failed_to_check.dart b/example/lib/screens/main/pages/update/components/failed_to_check.dart deleted file mode 100644 index 60b762ba..00000000 --- a/example/lib/screens/main/pages/update/components/failed_to_check.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; - -class FailedToCheck extends StatelessWidget { - const FailedToCheck({ - super.key, - }); - - @override - Widget build(BuildContext context) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon( - Icons.error, - size: 38, - color: Colors.red, - ), - SizedBox(height: 10), - Text( - 'Failed To Check For Updates\nThe remote URL could not be reached', - textAlign: TextAlign.center, - ), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/update/components/header.dart b/example/lib/screens/main/pages/update/components/header.dart deleted file mode 100644 index b3efb980..00000000 --- a/example/lib/screens/main/pages/update/components/header.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -class Header extends StatelessWidget { - const Header({ - super.key, - required this.title, - }); - - final String title; - - @override - Widget build(BuildContext context) => Text( - title, - style: GoogleFonts.openSans( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ); -} diff --git a/example/lib/screens/main/pages/update/components/up_to_date.dart b/example/lib/screens/main/pages/update/components/up_to_date.dart deleted file mode 100644 index 8cbf5c91..00000000 --- a/example/lib/screens/main/pages/update/components/up_to_date.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter/material.dart'; - -class UpToDate extends StatelessWidget { - const UpToDate({ - super.key, - }); - - @override - Widget build(BuildContext context) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon( - Icons.done, - size: 38, - ), - SizedBox(height: 10), - Text( - 'Up To Date', - textAlign: TextAlign.center, - ), - Text( - "with the latest example app from the 'main' branch", - textAlign: TextAlign.center, - style: TextStyle(fontStyle: FontStyle.italic), - ), - ], - ), - ); -} diff --git a/example/lib/screens/main/pages/update/components/update_available.dart b/example/lib/screens/main/pages/update/components/update_available.dart deleted file mode 100644 index 6669e0f9..00000000 --- a/example/lib/screens/main/pages/update/components/update_available.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -Center buildUpdateAvailableWidget({ - required BuildContext context, - required String availableVersion, - required String currentVersion, - required void Function() updateApplication, -}) => - Center( - child: Flex( - mainAxisAlignment: MainAxisAlignment.center, - direction: MediaQuery.of(context).size.width < 400 - ? Axis.vertical - : Axis.horizontal, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'New Version Available!', - style: GoogleFonts.openSans( - fontWeight: FontWeight.bold, - fontSize: 24, - ), - textAlign: TextAlign.center, - ), - Text( - '$availableVersion > $currentVersion', - style: const TextStyle(fontSize: 20), - ), - ], - ), - const SizedBox( - width: 30, - height: 12, - ), - IconButton( - iconSize: 38, - icon: const Icon( - Icons.download, - color: Colors.green, - ), - onPressed: updateApplication, - ), - ], - ), - ); diff --git a/example/lib/screens/main/pages/update/update.dart b/example/lib/screens/main/pages/update/update.dart deleted file mode 100644 index c514b5e1..00000000 --- a/example/lib/screens/main/pages/update/update.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'dart:io'; - -import 'package:better_open_file/better_open_file.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show rootBundle; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; -import 'package:version/version.dart'; - -import '../../../../shared/components/loading_indicator.dart'; -import 'components/failed_to_check.dart'; -import 'components/header.dart'; -import 'components/up_to_date.dart'; -import 'components/update_available.dart'; - -class UpdatePage extends StatefulWidget { - const UpdatePage({super.key}); - - @override - State createState() => _UpdatePageState(); -} - -class _UpdatePageState extends State { - static const String versionURL = - 'https://raw.githubusercontent.com/JaffaKetchup/flutter_map_tile_caching/main/example/currentAppVersion.txt'; - static const String windowsURL = - 'https://github.com/JaffaKetchup/flutter_map_tile_caching/blob/main/prebuiltExampleApplications/WindowsApplication.exe?raw=true'; - static const String androidURL = - 'https://github.com/JaffaKetchup/flutter_map_tile_caching/blob/main/prebuiltExampleApplications/AndroidApplication.apk?raw=true'; - - bool updating = false; - - Future updateApplication() async { - setState(() => updating = true); - - final http.Response response = await http.get( - Uri.parse(Platform.isWindows ? windowsURL : androidURL), - ); - final File file = File( - p.join( - // ignore: invalid_use_of_internal_member, invalid_use_of_protected_member - FMTC.instance.rootDirectory.directory.absolute.path, - 'newAppVersion.${Platform.isWindows ? 'exe' : 'apk'}', - ), - ); - - await file.create(); - await file.writeAsBytes(response.bodyBytes); - - if (Platform.isWindows) { - await Process.start(file.absolute.path, []); - } else { - await OpenFile.open(file.absolute.path); - } - } - - @override - Widget build(BuildContext context) => Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Header(title: 'Update App'), - const SizedBox(height: 12), - Expanded( - child: updating - ? const LoadingIndicator( - message: - 'Downloading New Application Installer...\nThe app will automatically exit and run installer once downloaded', - ) - : FutureBuilder( - future: rootBundle.loadString( - 'currentAppVersion.txt', - cache: false, - ), - builder: (context, currentVersion) => currentVersion - .hasData - ? FutureBuilder( - future: http.read(Uri.parse(versionURL)), - builder: (context, availableVersion) => - availableVersion.hasError - ? const FailedToCheck() - : availableVersion.hasData - ? Version.parse( - availableVersion.data! - .trim(), - ) > - Version.parse( - currentVersion.data! - .trim(), - ) - ? buildUpdateAvailableWidget( - context: context, - availableVersion: - availableVersion.data! - .trim(), - currentVersion: - currentVersion.data! - .trim(), - updateApplication: - updateApplication, - ) - : const UpToDate() - : const LoadingIndicator( - message: - 'Checking For Updates...', - ), - ) - : const LoadingIndicator( - message: 'Loading App Version Information...', - ), - ), - ), - ], - ), - ), - ), - ); -} diff --git a/example/lib/screens/store_editor/components/header.dart b/example/lib/screens/store_editor/components/header.dart index 30e18ade..8ce3704b 100644 --- a/example/lib/screens/store_editor/components/header.dart +++ b/example/lib/screens/store_editor/components/header.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:provider/provider.dart'; -import '../../../shared/state/download_provider.dart'; +//import '../../../shared/state/download_provider.dart'; import '../../../shared/state/general_provider.dart'; import '../store_editor.dart'; @@ -41,45 +41,39 @@ AppBar buildHeader({ if (formKey.currentState!.validate()) { formKey.currentState!.save(); - final StoreDirectory? existingStore = - widget.existingStoreName == null - ? null - : FMTC.instance(widget.existingStoreName!); - final StoreDirectory newStore = existingStore == null - ? FMTC.instance(newValues['storeName']!) + 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); + /*final downloadProvider = + Provider.of(context, listen: false); if (existingStore != null && downloadProvider.selectedStore == existingStore) { downloadProvider.setSelectedStore(newStore); - } + }*/ - await newStore.manage.createAsync(); + if (existingStore == null) await newStore.manage.create(); - await newStore.metadata.addAsync( + // 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.addAsync( - key: 'validDuration', - value: newValues['validDuration']!, - ); - await newStore.metadata.addAsync( - key: 'maxLength', - value: newValues['maxLength']!, + await newStore.metadata.setBulk( + kvs: { + 'validDuration': newValues['validDuration']!, + 'maxLength': newValues['maxLength']!, + if (widget.existingStoreName == null || useNewCacheModeValue) + 'behaviour': cacheModeValue ?? 'cacheFirst', + }, ); - if (widget.existingStoreName == null || useNewCacheModeValue) { - await newStore.metadata.addAsync( - key: 'behaviour', - value: cacheModeValue ?? 'cacheFirst', - ); - } - - if (!mounted) return; + if (!context.mounted) return; if (widget.isStoreInUse && widget.existingStoreName != null) { Provider.of(context, listen: false) .currentStore = newValues['storeName']; @@ -91,6 +85,7 @@ AppBar buildHeader({ const SnackBar(content: Text('Saved successfully')), ); } else { + if (!context.mounted) return; ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -101,6 +96,6 @@ AppBar buildHeader({ ); } }, - ) + ), ], ); diff --git a/example/lib/screens/store_editor/store_editor.dart b/example/lib/screens/store_editor/store_editor.dart index c6632b2a..fe404d9b 100644 --- a/example/lib/screens/store_editor/store_editor.dart +++ b/example/lib/screens/store_editor/store_editor.dart @@ -7,8 +7,8 @@ import 'package:provider/provider.dart'; import 'package:validators/validators.dart' as validators; import '../../shared/components/loading_indicator.dart'; -import '../../shared/state/download_provider.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 { @@ -44,233 +44,216 @@ class _StoreEditorPopupState extends State { } @override - Widget build(BuildContext context) => Consumer( - builder: (context, downloadProvider, _) => WillPopScope( - onWillPop: () async { - scaffoldMessenger.showSnackBar( - const SnackBar(content: Text('Changes not saved')), - ); - return true; - }, - child: 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(() => {}) - : FMTC - .instance(widget.existingStoreName!) - .metadata - .readAsync, - builder: (context, metadata) { - if (!metadata.hasData || metadata.data == null) { - return const LoadingIndicator( - message: - 'Loading Settings...\n\nSeeing this screen for a long time?\nThere may be a misconfiguration of the\nstore. Try disabling caching and deleting\n faulty stores.', - ); - } - return Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - children: [ - TextFormField( - decoration: const InputDecoration( - labelText: 'Store Name', - helperText: 'Must be valid directory name', - prefixIcon: Icon(Icons.text_fields), - isDense: true, - ), - onChanged: (input) async { - _storeNameIsDuplicate = (await FMTC - .instance - .rootDirectory - .stats - .storesAvailableAsync) - .contains(FMTC.instance(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, + 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, ), - const SizedBox(height: 5), - TextFormField( - decoration: const InputDecoration( - labelText: 'Map Source URL (protocol required)', - helperText: - "Use '{x}', '{y}', '{z}' as placeholders. Omit subdomain.", - prefixIcon: Icon(Icons.link), - isDense: true, - ), - onChanged: (i) async { - _httpRequestFailed = await http - .get( - Uri.parse( - NetworkTileProvider().getTileUrl( - const TileCoordinates(1, 1, 1), - TileLayer(urlTemplate: i), - ), - ), - ) - .then( - (res) => res.statusCode == 200 - ? null - : 'HTTP Request Failed', - onError: (_) => 'HTTP Request Failed', - ); + 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), + ), + ); - setState(() {}); - }, - validator: (i) { - final String input = i ?? ''; + if (uri == null) { + setState( + () => _httpRequestFailed = 'Invalid URL', + ); + return; + } - 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)'; - } + _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 ?? ''; - 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 days for infinite duration', - 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, + 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, ), - const SizedBox(height: 5), - TextFormField( - decoration: const InputDecoration( - labelText: 'Maximum Length', - helperText: - 'Use 0 days for infinite number of tiles', - 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 - ? '20000' - : metadata.data!['maxLength'], - textInputAction: TextInputAction.done, + 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, ), - 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(), + 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/screens/main/pages/map/build_attribution.dart b/example/lib/shared/components/build_attribution.dart similarity index 73% rename from example/lib/screens/main/pages/map/build_attribution.dart rename to example/lib/shared/components/build_attribution.dart index 3837fe69..02f3ef1f 100644 --- a/example/lib/screens/main/pages/map/build_attribution.dart +++ b/example/lib/shared/components/build_attribution.dart @@ -1,12 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -List buildStdAttribution( - String urlTemplate, { - AttributionAlignment alignment = AttributionAlignment.bottomRight, -}) => - [ - RichAttributionWidget( +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 @@ -29,5 +35,5 @@ List buildStdAttribution( tooltip: 'flutter_map_tile_caching', ), ], - ), - ]; + ); +} diff --git a/example/lib/shared/components/loading_indicator.dart b/example/lib/shared/components/loading_indicator.dart index 1cb26ebc..99a47058 100644 --- a/example/lib/shared/components/loading_indicator.dart +++ b/example/lib/shared/components/loading_indicator.dart @@ -1,23 +1,22 @@ import 'package:flutter/material.dart'; class LoadingIndicator extends StatelessWidget { - const LoadingIndicator({ - super.key, - this.message = 'Please Wait...', - }); + const LoadingIndicator(this.text, {super.key}); - final String message; + final String text; @override Widget build(BuildContext context) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const CircularProgressIndicator(), - const SizedBox(height: 10), - Text( - message, + 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 new file mode 100644 index 00000000..212ed111 --- /dev/null +++ b/example/lib/shared/misc/circular_buffer.dart @@ -0,0 +1,61 @@ +// 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/misc/exts/interleave.dart b/example/lib/shared/misc/exts/interleave.dart new file mode 100644 index 00000000..1b92b856 --- /dev/null +++ b/example/lib/shared/misc/exts/interleave.dart @@ -0,0 +1,8 @@ +extension IterableExt on Iterable { + Iterable interleave(E separator) sync* { + for (int i = 0; i < length; i++) { + yield elementAt(i); + if (i < length - 1) yield separator; + } + } +} diff --git a/example/lib/shared/vars/size_formatter.dart b/example/lib/shared/misc/exts/size_formatter.dart similarity index 100% rename from example/lib/shared/vars/size_formatter.dart rename to example/lib/shared/misc/exts/size_formatter.dart diff --git a/example/lib/shared/misc/region_selection_method.dart b/example/lib/shared/misc/region_selection_method.dart new file mode 100644 index 00000000..10b42276 --- /dev/null +++ b/example/lib/shared/misc/region_selection_method.dart @@ -0,0 +1,4 @@ +enum RegionSelectionMethod { + useMapCenter, + usePointer, +} diff --git a/example/lib/shared/misc/region_type.dart b/example/lib/shared/misc/region_type.dart new file mode 100644 index 00000000..171bad1a --- /dev/null +++ b/example/lib/shared/misc/region_type.dart @@ -0,0 +1,6 @@ +enum RegionType { + square, + circle, + line, + customPolygon, +} diff --git a/example/lib/shared/state/download_provider.dart b/example/lib/shared/state/download_provider.dart deleted file mode 100644 index dc099b1e..00000000 --- a/example/lib/shared/state/download_provider.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; - -import '../vars/region_mode.dart'; - -class DownloadProvider extends ChangeNotifier { - RegionMode _regionMode = RegionMode.square; - RegionMode get regionMode => _regionMode; - set regionMode(RegionMode newMode) { - _regionMode = newMode; - notifyListeners(); - } - - BaseRegion? _region; - BaseRegion? get region => _region; - set region(BaseRegion? newRegion) { - _region = newRegion; - notifyListeners(); - } - - int? _regionTiles; - int? get regionTiles => _regionTiles; - set regionTiles(int? newNum) { - _regionTiles = newNum; - notifyListeners(); - } - - int _minZoom = 1; - 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(); - } - - StoreDirectory? _selectedStore; - StoreDirectory? get selectedStore => _selectedStore; - void setSelectedStore(StoreDirectory? newStore, {bool notify = true}) { - _selectedStore = newStore; - if (notify) notifyListeners(); - } - - final StreamController _manualPolygonRecalcTrigger = - StreamController.broadcast(); - StreamController get manualPolygonRecalcTrigger => - _manualPolygonRecalcTrigger; - void triggerManualPolygonRecalc() => _manualPolygonRecalcTrigger.add(null); - - Stream? _downloadProgress; - Stream? get downloadProgress => _downloadProgress; - void setDownloadProgress( - Stream? newStream, { - bool notify = true, - }) { - _downloadProgress = newStream; - if (notify) notifyListeners(); - } - - bool _preventRedownload = false; - bool get preventRedownload => _preventRedownload; - set preventRedownload(bool newBool) { - _preventRedownload = newBool; - notifyListeners(); - } - - bool _seaTileRemoval = true; - bool get seaTileRemoval => _seaTileRemoval; - set seaTileRemoval(bool newBool) { - _seaTileRemoval = newBool; - notifyListeners(); - } - - bool _disableRecovery = false; - bool get disableRecovery => _disableRecovery; - set disableRecovery(bool newBool) { - _disableRecovery = newBool; - notifyListeners(); - } - - DownloadBufferMode _bufferMode = DownloadBufferMode.tiles; - DownloadBufferMode get bufferMode => _bufferMode; - set bufferMode(DownloadBufferMode newMode) { - _bufferMode = newMode; - _bufferingAmount = newMode == DownloadBufferMode.tiles ? 500 : 5000; - notifyListeners(); - } - - int _bufferingAmount = 500; - int get bufferingAmount => _bufferingAmount; - set bufferingAmount(int newNum) { - _bufferingAmount = newNum; - notifyListeners(); - } -} diff --git a/example/lib/shared/vars/region_mode.dart b/example/lib/shared/vars/region_mode.dart deleted file mode 100644 index 2275027f..00000000 --- a/example/lib/shared/vars/region_mode.dart +++ /dev/null @@ -1,6 +0,0 @@ -enum RegionMode { - square, - rectangleVertical, - rectangleHorizontal, - circle, -} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 59dac777..f1f7825b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -3,37 +3,38 @@ description: The example application for 'flutter_map_tile_caching', showcasing it's functionality and use-cases. publish_to: "none" -version: 8.0.0 +version: 9.0.0 environment: - sdk: ">=2.18.0 <3.0.0" - flutter: ">=3.7.0" + sdk: ">=3.3.0 <4.0.0" + flutter: ">=3.19.0" dependencies: - badges: ^3.0.2 - better_open_file: ^3.6.4 + auto_size_text: ^3.0.0 + badges: ^3.1.2 + better_open_file: ^3.6.5 + collection: ^1.18.0 + dart_earcut: ^1.1.0 + file_picker: ^8.0.0+1 flutter: sdk: flutter - flutter_foreground_task: ^3.9.0 - flutter_map: ^4.0.0 + flutter_foreground_task: ^6.1.3 + flutter_map: ^6.1.0 + flutter_map_animations: ^0.6.0 flutter_map_tile_caching: - flutter_speed_dial: ^6.0.0 - fmtc_plus_background_downloading: ^7.0.0 - fmtc_plus_sharing: ^8.0.0 - google_fonts: ^3.0.1 - http: ^0.13.4 - intl: ^0.18.0 - latlong2: ^0.8.1 - osm_nominatim: ^2.0.1 - path: ^1.8.3 - provider: ^6.0.3 - shared_preferences: ^2.0.15 - stream_transform: ^2.0.0 + google_fonts: ^6.2.1 + gpx: ^2.2.2 + http: ^1.2.1 + intl: ^0.19.0 + latlong2: ^0.9.1 + osm_nominatim: ^3.0.0 + path: ^1.9.0 + path_provider: ^2.1.2 + provider: ^6.1.2 + stream_transform: ^2.1.0 validators: ^3.0.0 version: ^3.0.2 -dev_dependencies: null - dependency_overrides: flutter_map_tile_caching: path: ../ @@ -42,4 +43,3 @@ flutter: uses-material-design: true assets: - assets/icons/ - - currentAppVersion.txt diff --git a/example/windows/CMakeLists.txt b/example/windows/CMakeLists.txt index c0270746..c09389c5 100644 --- a/example/windows/CMakeLists.txt +++ b/example/windows/CMakeLists.txt @@ -8,7 +8,7 @@ set(BINARY_NAME "example") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. -cmake_policy(SET CMP0063 NEW) +cmake_policy(VERSION 3.14...3.25) # Define build configuration option. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) @@ -52,6 +52,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) diff --git a/example/windows/flutter/CMakeLists.txt b/example/windows/flutter/CMakeLists.txt index 930d2071..903f4899 100644 --- a/example/windows/flutter/CMakeLists.txt +++ b/example/windows/flutter/CMakeLists.txt @@ -10,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index 50c5a45b..a84779d7 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -6,18 +6,9 @@ #include "generated_plugin_registrant.h" -#include -#include -#include -#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { - IsarFlutterLibsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin")); - PermissionHandlerWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); - SharePlusWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); - UrlLauncherWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherWindows")); + ObjectboxFlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin")); } diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 200e5670..9f0138ed 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -3,10 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST - isar_flutter_libs - permission_handler_windows - share_plus - url_launcher_windows + objectbox_flutter_libs ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc index 15dbdb2f..13007a4b 100644 --- a/example/windows/runner/Runner.rc +++ b/example/windows/runner/Runner.rc @@ -89,7 +89,7 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "dev.org.fmtc.example" "\0" + VALUE "CompanyName", "dev.jaffaketchup.fmtc.demo" "\0" VALUE "FileDescription", "FMTC Demo" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "FMTC Demo" "\0" diff --git a/example/windows/runner/flutter_window.cpp b/example/windows/runner/flutter_window.cpp index b25e363e..955ee303 100644 --- a/example/windows/runner/flutter_window.cpp +++ b/example/windows/runner/flutter_window.cpp @@ -31,6 +31,11 @@ bool FlutterWindow::OnCreate() { this->Show(); }); + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + return true; } diff --git a/example/windows/runner/utils.cpp b/example/windows/runner/utils.cpp index f5bf9fa0..b2b08734 100644 --- a/example/windows/runner/utils.cpp +++ b/example/windows/runner/utils.cpp @@ -47,16 +47,17 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string) { } int target_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr); + -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); int converted_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, utf8_string.data(), - target_length, nullptr, nullptr); + input_length, utf8_string.data(), target_length, nullptr, nullptr); if (converted_length == 0) { return std::string(); } diff --git a/example/windows/runner/win32_window.cpp b/example/windows/runner/win32_window.cpp index 041a3855..60608d0f 100644 --- a/example/windows/runner/win32_window.cpp +++ b/example/windows/runner/win32_window.cpp @@ -60,7 +60,7 @@ class WindowClassRegistrar { public: ~WindowClassRegistrar() = default; - // Returns the singleton registar instance. + // Returns the singleton registrar instance. static WindowClassRegistrar* GetInstance() { if (!instance_) { instance_ = new WindowClassRegistrar(); diff --git a/example/windows/runner/win32_window.h b/example/windows/runner/win32_window.h index c86632d8..e901dde6 100644 --- a/example/windows/runner/win32_window.h +++ b/example/windows/runner/win32_window.h @@ -77,7 +77,7 @@ class Win32Window { // OS callback called by message pump. Handles the WM_NCCREATE message which // is passed when the non-client area is being created and enables automatic // non-client DPI scaling so that the non-client area automatically - // responsponds to changes in DPI. All other messages are handled by + // responds to changes in DPI. All other messages are handled by // MessageHandler. static LRESULT CALLBACK WndProc(HWND const window, UINT const message, diff --git a/example/windowsBuilder.bat b/example/windowsBuilder.bat deleted file mode 100644 index b096174c..00000000 --- a/example/windowsBuilder.bat +++ /dev/null @@ -1,4 +0,0 @@ -@ECHO OFF - -flutter clean | more -flutter build windows --obfuscate --split-debug-info=/symbols | more \ No newline at end of file diff --git a/jaffa_lints.yaml b/jaffa_lints.yaml index c7dab103..00d25b89 100644 --- a/jaffa_lints.yaml +++ b/jaffa_lints.yaml @@ -1,9 +1,10 @@ linter: rules: - always_declare_return_types - - always_require_non_null_named_parameters - annotate_overrides + - annotate_redeclares - avoid_annotating_with_dynamic + - avoid_bool_literals_in_conditional_expressions - avoid_catching_errors - avoid_double_and_int_checks - avoid_dynamic_calls @@ -24,8 +25,6 @@ linter: - avoid_relative_lib_imports - avoid_renaming_method_parameters - avoid_return_types_on_setters - - avoid_returning_null - - avoid_returning_null_for_future - avoid_returning_null_for_void - avoid_returning_this - avoid_setters_without_getters @@ -45,13 +44,17 @@ linter: - cascade_invocations - cast_nullable_to_non_nullable - close_sinks + - collection_methods_unrelated_type + - combinators_ordering - comment_references - conditional_uri_does_not_exist - constant_identifier_names - control_flow_in_finally - curly_braces_in_flow_control_structures + - dangling_library_doc_comments - depend_on_referenced_packages - deprecated_consistency + - deprecated_member_use_from_same_package - directives_ordering - do_not_use_environment - empty_catches @@ -62,22 +65,28 @@ linter: - file_names - hash_and_equals - implementation_imports - - iterable_contains_unrelated_type + - implicit_call_tearoffs + - implicit_reopen + - invalid_case_patterns - join_return_with_assignment - leading_newlines_in_multiline_strings + - library_annotations - library_names - library_prefixes - library_private_types_in_public_api - - list_remove_unrelated_type - literal_only_boolean_expressions + - matching_super_parameters - missing_whitespace_between_adjacent_strings - no_adjacent_strings_in_list - no_default_cases - no_duplicate_case_values - no_leading_underscores_for_library_prefixes - no_leading_underscores_for_local_identifiers + - no_literal_bool_comparisons - no_logic_in_create_state - no_runtimeType_toString + - no_self_assignments + - no_wildcard_variable_uses - non_constant_identifier_names - noop_primitive_operations - null_check_on_nullable_type_parameter @@ -100,7 +109,6 @@ linter: - prefer_const_literals_to_create_immutables - prefer_constructors_over_static_methods - prefer_contains - - prefer_equal_for_default_values - prefer_expression_function_bodies - prefer_final_fields - prefer_final_in_for_each @@ -109,6 +117,7 @@ linter: - prefer_foreach - prefer_function_declarations_over_variables - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions - prefer_if_null_operators - prefer_initializing_formals - prefer_inlined_adds @@ -127,6 +136,7 @@ linter: - prefer_typing_uninitialized_variables - prefer_void_to_null - provide_deprecation_message + - public_member_api_docs - recursive_getters - require_trailing_commas - secure_pubspec_urls @@ -134,6 +144,7 @@ linter: - sized_box_shrink_expand - slash_for_doc_comments - sort_child_properties_last + - sort_constructors_first - sort_pub_dependencies - sort_unnamed_constructors_first - test_types_in_equals @@ -141,16 +152,20 @@ linter: - tighten_type_of_initializing_formals - type_annotate_public_apis - type_init_formals + - type_literal_in_constant_pattern - unawaited_futures - unnecessary_await_in_return - unnecessary_brace_in_string_interps + - unnecessary_breaks - unnecessary_const - unnecessary_constructor_name - unnecessary_getters_setters - unnecessary_lambdas - unnecessary_late + - unnecessary_library_directive - unnecessary_new - unnecessary_null_aware_assignments + - unnecessary_null_aware_operator_on_extension_on_nullable - unnecessary_null_checks - unnecessary_null_in_if_null_operators - unnecessary_nullable_for_final_variable_declarations @@ -161,6 +176,8 @@ linter: - unnecessary_string_escapes - unnecessary_string_interpolations - unnecessary_this + - unnecessary_to_list_in_spreads + - unreachable_from_main - unrelated_type_equality_checks - unsafe_html - use_build_context_synchronously @@ -178,8 +195,9 @@ linter: - use_rethrow_when_possible - use_setters_to_change_properties - use_string_buffers + - use_string_in_part_of_directives - use_super_parameters - use_test_throws_matchers - use_to_and_as_if_applicable - valid_regexps - - void_checks + - void_checks \ No newline at end of file diff --git a/lib/custom_backend_api.dart b/lib/custom_backend_api.dart new file mode 100644 index 00000000..ff029497 --- /dev/null +++ b/lib/custom_backend_api.dart @@ -0,0 +1,18 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +/// Specialised sub-library of FMTC which provides access to some semi-public +/// internals necessary to create custom backends or work more directly with +/// them +/// +/// 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] +/// > 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; + +export 'src/backend/export_internal.dart'; diff --git a/lib/flutter_map_tile_caching.dart b/lib/flutter_map_tile_caching.dart index 08106afd..a731dddb 100644 --- a/lib/flutter_map_tile_caching.dart +++ b/lib/flutter_map_tile_caching.dart @@ -1,9 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -/// A plugin for flutter_map providing advanced caching functionality, with -/// ability to download map regions for offline use. Also includes useful -/// prebuilt widgets. +/// A plugin for 'flutter_map' providing advanced offline functionality /// /// * [GitHub Repository](https://github.com/JaffaKetchup/flutter_map_tile_caching) /// * [pub.dev Package](https://pub.dev/packages/flutter_map_tile_caching) @@ -13,8 +11,11 @@ library flutter_map_tile_caching; import 'dart:async'; +import 'dart:collection'; import 'dart:io'; +import 'dart:isolate'; import 'dart:math' as math; +import 'dart:math'; import 'package:async/async.dart'; import 'package:collection/collection.dart'; @@ -22,63 +23,44 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/plugin_api.dart'; -import 'package:http/http.dart'; +import 'package:http/http.dart' as http; import 'package:http/io_client.dart'; -import 'package:http_plus/http_plus.dart'; -import 'package:isar/isar.dart'; import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; -import 'package:stream_transform/stream_transform.dart'; -import 'package:watcher/watcher.dart'; -import 'src/bulk_download/bulk_tile_writer.dart'; -import 'src/bulk_download/downloader.dart'; -import 'src/bulk_download/internal_timing_progress_management.dart'; +import 'src/backend/export_external.dart'; +import 'src/backend/export_internal.dart'; +import 'src/bulk_download/instance.dart'; +import 'src/bulk_download/rate_limited_stream.dart'; import 'src/bulk_download/tile_loops/shared.dart'; -import 'src/bulk_download/tile_progress.dart'; -import 'src/db/defs/metadata.dart'; -import 'src/db/defs/recovery.dart'; -import 'src/db/defs/store_descriptor.dart'; -import 'src/db/defs/tile.dart'; -import 'src/db/registry.dart'; -import 'src/db/tools.dart'; -import 'src/errors/browsing.dart'; -import 'src/errors/initialisation.dart'; -import 'src/errors/store_not_ready.dart'; -import 'src/misc/exts.dart'; -import 'src/misc/typedefs.dart'; +import 'src/misc/int_extremes.dart'; +import 'src/misc/obscure_query_params.dart'; +import 'src/providers/browsing_errors.dart'; import 'src/providers/image_provider.dart'; -export 'src/errors/browsing.dart'; -export 'src/errors/damaged_store.dart'; -export 'src/errors/initialisation.dart'; -export 'src/errors/store_not_ready.dart'; -export 'src/misc/typedefs.dart'; +export 'src/backend/export_external.dart'; +export 'src/providers/browsing_errors.dart'; part 'src/bulk_download/download_progress.dart'; -part 'src/fmtc.dart'; -part 'src/misc/store_db_impl.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/regions/base_region.dart'; part 'src/regions/circle.dart'; +part 'src/regions/custom_polygon.dart'; part 'src/regions/downloadable_region.dart'; part 'src/regions/line.dart'; part 'src/regions/recovered_region.dart'; part 'src/regions/rectangle.dart'; -part 'src/root/directory.dart'; -part 'src/root/import.dart'; -part 'src/root/manage.dart'; -part 'src/root/migrator.dart'; +part 'src/root/root.dart'; +part 'src/root/external.dart'; part 'src/root/recovery.dart'; part 'src/root/statistics.dart'; -part 'src/settings/fmtc_settings.dart'; -part 'src/settings/tile_provider_settings.dart'; -part 'src/store/directory.dart'; +part 'src/store/store.dart'; part 'src/store/download.dart'; -part 'src/store/export.dart'; part 'src/store/manage.dart'; part 'src/store/metadata.dart'; part 'src/store/statistics.dart'; diff --git a/lib/fmtc_module_api.dart b/lib/fmtc_module_api.dart deleted file mode 100644 index ff8a07a3..00000000 --- a/lib/fmtc_module_api.dart +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -// ignore_for_file: invalid_export_of_internal_element - -/// Restricted API which exports internal functionality, necessary for the FMTC -/// modules to work correctly -/// -/// When importing this library, also import 'flutter_map_tile_caching.dart' for -/// the full functionality set. -/// -/// --- -/// -/// "With great power comes great responsibility" - Someone -/// -/// This library forms part of a layer of abstraction between you, FMTC -/// internals, and underlying databases. Importing this library removes that -/// abstraction, making it easy to disrupt FMTC's normal operations with -/// incorrect usage. For example, it is possible to force close an open Isar -/// database, leading to an erroneous & invalid state. -/// -/// If you are using this to create a custom module, go ahead! Please do get in -/// touch, I'm always interested to hear what the community is making, and I may -/// be able to offer some insight into the darker corners and workings of FMTC. -/// Note that not necessarily all internal APIs are exposed through this library. -/// -/// **Do not use in normal applications. I may be unable to offer support.** -library fmtc_module_api; - -export 'src/db/defs/metadata.dart'; -export 'src/db/defs/store_descriptor.dart'; -export 'src/db/defs/tile.dart'; -export 'src/db/registry.dart'; -export 'src/db/tools.dart'; -export 'src/misc/exts.dart'; diff --git a/lib/src/backend/backend_access.dart b/lib/src/backend/backend_access.dart new file mode 100644 index 00000000..371d0757 --- /dev/null +++ b/lib/src/backend/backend_access.dart @@ -0,0 +1,68 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'package:meta/meta.dart' as meta; + +import 'export_external.dart'; + +/// Provides access to the thread-seperate backend internals +/// ([FMTCBackendInternal]) globally with some level of access control +/// +/// {@template fmtc.backend.access} +/// +/// Only a single backend may set the [internal] backend at any one time, +/// essentially providing a locking mechanism preventing multiple backends from +/// being used at the same time (with a shared access). +/// +/// A [FMTCBackendInternal] implementation can access the [internal] setters, +/// and should set them both sequentially at the end of the +/// [FMTCBackend.initialise] & [FMTCBackend.uninitialise] implementations. +/// +/// The [internal] getter(s) should never be used outside of FMTC internals, as +/// it provides access to potentially uncontrolled, and unorganised, methods. +/// {@endtemplate} +abstract mixin class FMTCBackendAccess { + static FMTCBackendInternal? _internal; + + /// Provides access to the thread-seperate backend internals + /// ([FMTCBackendInternal]) globally with some level of access control + /// + /// {@macro fmtc.backend.access} + @meta.internal + @meta.experimental + static FMTCBackendInternal get internal => + _internal ?? (throw RootUnavailable()); + + @meta.protected + static set internal(FMTCBackendInternal? newInternal) { + if (newInternal != null && _internal != null) { + throw RootAlreadyInitialised(); + } + _internal = newInternal; + } +} + +/// Provides access to the thread-seperate backend internals +/// ([FMTCBackendInternalThreadSafe]) globally with some level of access control +/// +/// {@macro fmtc.backend.access} +abstract mixin class FMTCBackendAccessThreadSafe { + static FMTCBackendInternalThreadSafe? _internal; + + /// Provides access to the thread-seperate backend internals + /// ([FMTCBackendInternalThreadSafe]) globally with some level of access control + /// + /// {@macro fmtc.backend.access} + @meta.internal + @meta.experimental + static FMTCBackendInternalThreadSafe get internal => + _internal ?? (throw RootUnavailable()); + + @meta.protected + static set internal(FMTCBackendInternalThreadSafe? newInternal) { + if (newInternal != null && _internal != null) { + throw RootAlreadyInitialised(); + } + _internal = newInternal; + } +} diff --git a/lib/src/backend/errors/basic.dart b/lib/src/backend/errors/basic.dart new file mode 100644 index 00000000..bd911b13 --- /dev/null +++ b/lib/src/backend/errors/basic.dart @@ -0,0 +1,39 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of 'errors.dart'; + +/// Indicates that the backend/root structure (ie. database and/or directory) was +/// not available for use in operations, because either: +/// * it was already closed +/// * it was never created +/// * it was closed immediately whilst this operation was in progress +final class RootUnavailable extends FMTCBackendError { + @override + String toString() => + 'RootUnavailable: The requested backend/root was unavailable'; +} + +/// Indicates that the backend/root structure could not be initialised, because +/// it was already initialised +final class RootAlreadyInitialised extends FMTCBackendError { + @override + String toString() => + 'RootAlreadyInitialised: The requested backend/root could not be ' + 'initialised because it was already initialised'; +} + +/// Indicates that the specified store structure was not available for use in +/// operations, likely because it didn't exist +final class StoreNotExists extends FMTCBackendError { + /// Indicates that the specified store structure was not available for use in + /// operations, likely because it didn't exist + StoreNotExists({required this.storeName}); + + /// The referenced store name + final String storeName; + + @override + String toString() => + 'StoreNotExists: The requested store "$storeName" did not exist'; +} diff --git a/lib/src/backend/errors/errors.dart b/lib/src/backend/errors/errors.dart new file mode 100644 index 00000000..52b25b88 --- /dev/null +++ b/lib/src/backend/errors/errors.dart @@ -0,0 +1,12 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part 'basic.dart'; +part 'import_export.dart'; + +/// An error to be thrown by backend implementations in known events only +/// +/// A backend can create custom errors of this type, which is useful to show +/// that the backend is throwing a known expected error, rather than an +/// unexpected one. +base class FMTCBackendError extends Error {} diff --git a/lib/src/backend/errors/import_export.dart b/lib/src/backend/errors/import_export.dart new file mode 100644 index 00000000..8017d10a --- /dev/null +++ b/lib/src/backend/errors/import_export.dart @@ -0,0 +1,71 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of 'errors.dart'; + +/// A subset of [FMTCBackendError]s that indicates a failure during import or +/// export, due to the extended reason +base class ImportExportError extends FMTCBackendError {} + +/// Indicates that the specified path to import from or export to did exist, but +/// was not a file +final class ImportExportPathNotFile extends ImportExportError { + /// Indicates that the specified path to import from or export to did exist, + /// but was not a file + ImportExportPathNotFile(); + + @override + String toString() => + 'ImportPathNotFile: The specified import/export path existed, but was not ' + 'a file.'; +} + +/// Indicates that the specified file to import did not exist/could not be found +final class ImportPathNotExists extends ImportExportError { + /// Indicates that the specified file to import did not exist/could not be + /// found + ImportPathNotExists({required this.path}); + + /// The specified path to the import file + final String path; + + @override + String toString() => + 'ImportPathNotExists: The specified import file ($path) did not exist.'; +} + +/// 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"). +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 + /// + /// The last bytes of the file must be hex "FF FF 46 4D 54 43" ("**FMTC"). + ImportFileNotFMTCStandard(); + + @override + String toString() => + 'ImportFileNotFMTCStandard: The import file was not of the expected ' + 'standard.'; +} + +/// 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". +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". + ImportFileNotBackendCompatible(); + + @override + String toString() => + 'ImportFileNotBackendCompatible: The import file was exported from a ' + 'different FMTC backend, and is not compatible with the current backend'; +} diff --git a/lib/src/backend/export_external.dart b/lib/src/backend/export_external.dart new file mode 100644 index 00000000..4d2890e0 --- /dev/null +++ b/lib/src/backend/export_external.dart @@ -0,0 +1,8 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +export 'errors/errors.dart'; +export 'impls/objectbox/backend/backend.dart'; +export 'interfaces/backend/backend.dart'; +export 'interfaces/backend/internal.dart'; +export 'interfaces/backend/internal_thread_safe.dart'; diff --git a/lib/src/backend/export_internal.dart b/lib/src/backend/export_internal.dart new file mode 100644 index 00000000..49c138c5 --- /dev/null +++ b/lib/src/backend/export_internal.dart @@ -0,0 +1,5 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +export 'backend_access.dart'; +export 'interfaces/models.dart'; diff --git a/lib/src/backend/impls/objectbox/backend/backend.dart b/lib/src/backend/impls/objectbox/backend/backend.dart new file mode 100644 index 00000000..6fe31a25 --- /dev/null +++ b/lib/src/backend/impls/objectbox/backend/backend.dart @@ -0,0 +1,76 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:collection/collection.dart'; +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +import '../../../../../flutter_map_tile_caching.dart'; +import '../../../export_internal.dart'; +import '../models/generated/objectbox.g.dart'; +import '../models/src/recovery.dart'; +import '../models/src/root.dart'; +import '../models/src/store.dart'; +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'; + +/// Implementation of [FMTCBackend] that uses ObjectBox as the storage database +final class FMTCObjectBoxBackend implements FMTCBackend { + /// {@macro fmtc.backend.initialise} + /// + /// --- + /// + /// [maxDatabaseSize] is the maximum size the database file can grow + /// to, in KB. Exceeding it throws [DbFullException]. Defaults to 10 GB. + /// + /// [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. + /// + /// Avoid using [useInMemoryDatabase] outside of testing purposes. + @override + Future initialise({ + String? rootDirectory, + int maxDatabaseSize = 10000000, + String? macosApplicationGroup, + @visibleForTesting bool useInMemoryDatabase = false, + }) => + FMTCObjectBoxBackendInternal._instance.initialise( + rootDirectory: rootDirectory, + maxDatabaseSize: maxDatabaseSize, + macosApplicationGroup: macosApplicationGroup, + useInMemoryDatabase: useInMemoryDatabase, + ); + + /// {@macro fmtc.backend.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, + bool immediate = false, + }) => + FMTCObjectBoxBackendInternal._instance + .uninitialise(deleteRoot: deleteRoot, immediate: immediate); +} diff --git a/lib/src/backend/impls/objectbox/backend/errors.dart b/lib/src/backend/impls/objectbox/backend/errors.dart new file mode 100644 index 00000000..ec1b6018 --- /dev/null +++ b/lib/src/backend/impls/objectbox/backend/errors.dart @@ -0,0 +1,24 @@ +// 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 new file mode 100644 index 00000000..9cdcac45 --- /dev/null +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -0,0 +1,632 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of 'backend.dart'; + +/// Internal implementation of [FMTCBackend] that uses ObjectBox as the storage +/// database +/// +/// Actual implementation performed by `_worker` via `_ObjectBoxBackendImpl`. +abstract interface class FMTCObjectBoxBackendInternal + implements FMTCBackendInternal { + static final _instance = _ObjectBoxBackendImpl._(); +} + +class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { + _ObjectBoxBackendImpl._(); + + @override + String get friendlyIdentifier => 'ObjectBox'; + + void get expectInitialised => _sendPort ?? (throw RootUnavailable()); + + late String rootDirectory; + + // Worker communication protocol storage + + SendPort? _sendPort; + final _workerResOneShot = ?>>{}; + final _workerResStreamed = ?>>{}; + int _workerId = 0; + late Completer _workerComplete; + late StreamSubscription _workerHandler; + + // `removeOldestTilesAboveLimit` tracking & debouncing + + Timer? _rotalDebouncer; + String? _rotalStore; + Completer? _rotalResultCompleter; + + // Define communicators + + Future?> _sendCmdOneShot({ + required _CmdType type, + Map args = const {}, + }) async { + expectInitialised; + + final id = ++_workerId; // Create new unique ID + _workerResOneShot[id] = Completer(); // Will be completed by direct handler + _sendPort!.send((id: id, type: type, args: args)); // Send cmd + + try { + return await _workerResOneShot[id]!.future; // Await response + } catch (err, stackTrace) { + Error.throwWithStackTrace( + err, + StackTrace.fromString( + '$stackTrace\n${StackTrace.current}' + '#+ [FMTC Debug Info] $type: $args\n', + ), + ); + } finally { + _workerResOneShot.remove(id); // Free memory + } + } + + Stream?> _sendCmdStreamed({ + required _CmdType type, + Map args = const {}, + }) async* { + expectInitialised; + + final id = ++_workerId; // Create new unique ID + final controller = StreamController?>( + onCancel: () async { + _workerResStreamed.remove(id); // Free memory + // Cancel the worker stream if the worker is alive + if ((type.hasInternalStreamSub ?? false) && + !_workerComplete.isCompleted) { + await _sendCmdOneShot( + type: _CmdType.cancelInternalStreamSub, + args: {'id': id}, + ); + } + }, + ); + _workerResStreamed[id] = + 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( + err, + StackTrace.fromString( + '$stackTrace\n#+ [FMTC] 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(); + } + } + + // Lifecycle implementations + + Future initialise({ + required String? rootDirectory, + required int maxDatabaseSize, + required String? macosApplicationGroup, + required bool useInMemoryDatabase, + }) async { + if (_sendPort != null) throw RootAlreadyInitialised(); + + if (useInMemoryDatabase) { + this.rootDirectory = Store.inMemoryPrefix + (rootDirectory ?? 'fmtc'); + } else { + await Directory( + this.rootDirectory = path.join( + rootDirectory ?? + (await getApplicationDocumentsDirectory()).absolute.path, + 'fmtc', + ), + ).create(recursive: true); + } + + // Prepare to recieve `SendPort` from worker + _workerResOneShot[0] = Completer(); + final workerInitialRes = _workerResOneShot[0]! + .future // Completed directly by handler below + .then<({ByteData? storeRef, Object? err, StackTrace? stackTrace})>( + (res) { + _workerResOneShot.remove(0); + _sendPort = res!['sendPort']; + + return ( + storeRef: res['storeReference'] as ByteData, + err: null, + stackTrace: null, + ); + }, + onError: (err, stackTrace) { + _workerHandler.cancel(); + _workerComplete.complete(); + + _workerId = 0; + _workerResOneShot.clear(); + _workerResStreamed.clear(); + + return (storeRef: null, err: err, stackTrace: stackTrace); + }, + ); + + // Setup worker comms/response handler + final receivePort = ReceivePort(); + _workerComplete = Completer(); + _workerHandler = receivePort.listen( + (evt) { + evt as ({int id, Map? data})?; + + // Killed forcefully by environment (eg. hot restart) + if (evt == null) { + _workerHandler.cancel(); // Ensure this handler is cancelled on return + _workerComplete.complete(); + // Doesn't require full cleanup, because hot restart has done that + return; + } + + final isStreamedResult = evt.data?['expectStream'] == true; + + // Handle errors + if (evt.data?['error'] case final err?) { + final stackTrace = evt.data!['stackTrace']; + if (isStreamedResult) { + _workerResStreamed[evt.id]?.addError(err, stackTrace); + } else { + _workerResOneShot[evt.id]!.completeError(err, stackTrace); + } + return; + } + + if (isStreamedResult) { + // May be `null` if cmd was streamed result, but has no way to prevent + // future results even after the listener has stopped + // + // See `_WorkerCmdType.hasInternalStreamSub` for info. + _workerResStreamed[evt.id]?.add(evt.data); + } else { + _workerResOneShot[evt.id]!.complete(evt.data); + } + }, + onDone: () => _workerComplete.complete(), + ); + + // Spawn worker isolate + await Isolate.spawn( + _worker, + ( + sendPort: receivePort.sendPort, + rootDirectory: this.rootDirectory, + maxDatabaseSize: maxDatabaseSize, + macosApplicationGroup: macosApplicationGroup, + rootIsolateToken: ServicesBinding.rootIsolateToken!, + ), + onExit: receivePort.sendPort, + debugName: '[FMTC] ObjectBox Backend Worker', + ); + + // Wait for initial response from isolate + final initResult = await workerInitialRes; + + // Check whether initialisation was successful + if (initResult.storeRef case final storeRef?) { + FMTCBackendAccess.internal = this; + FMTCBackendAccessThreadSafe.internal = + _ObjectBoxBackendThreadSafeImpl._(storeReference: storeRef); + } else { + Error.throwWithStackTrace(initResult.err!, initResult.stackTrace!); + } + } + + Future uninitialise({ + required bool deleteRoot, + required bool immediate, + }) async { + expectInitialised; + + // Wait for all currently underway operations to complete before destroying + // the isolate (if not `immediate`) + if (!immediate) { + await Future.wait(_workerResOneShot.values.map((e) => e.future)); + } + + // Send self-destruct cmd to worker, and wait for response and exit + await _sendCmdOneShot( + type: _CmdType.destroy, + args: {'deleteRoot': deleteRoot}, + ); + await _workerComplete.future; + + // Destroy remaining worker refs + _sendPort = null; // Indicate ready for re-init + await _workerHandler.cancel(); // Stop response handler + + // Kill any remaining operations with an error (they'll never recieve a + // response from the worker) + for (final completer in _workerResOneShot.values) { + completer.complete({'error': RootUnavailable()}); + } + for (final streamController in List.of(_workerResStreamed.values)) { + await streamController.close(); + } + + // Reset state + _workerId = 0; + _workerResOneShot.clear(); + _workerResStreamed.clear(); + _rotalDebouncer?.cancel(); + _rotalDebouncer = null; + _rotalStore = null; + _rotalResultCompleter?.completeError(RootUnavailable()); + _rotalResultCompleter = null; + + FMTCBackendAccess.internal = null; + FMTCBackendAccessThreadSafe.internal = null; + } + + // Implementation & worker connectors + + @override + Future realSize() async => + (await _sendCmdOneShot(type: _CmdType.realSize))!['size']; + + @override + Future rootSize() async => + (await _sendCmdOneShot(type: _CmdType.rootSize))!['size']; + + @override + Future rootLength() async => + (await _sendCmdOneShot(type: _CmdType.rootLength))!['length']; + + @override + Future> listStores() async => + (await _sendCmdOneShot(type: _CmdType.listStores))!['stores']; + + @override + Future storeExists({ + required String storeName, + }) async => + (await _sendCmdOneShot( + type: _CmdType.storeExists, + args: {'storeName': storeName}, + ))!['exists']; + + @override + Future createStore({ + required String storeName, + }) => + _sendCmdOneShot( + type: _CmdType.createStore, + args: {'storeName': storeName}, + ); + + @override + Future resetStore({ + required String storeName, + }) => + _sendCmdOneShot( + type: _CmdType.resetStore, + args: {'storeName': storeName}, + ); + + @override + Future renameStore({ + required String currentStoreName, + required String newStoreName, + }) => + _sendCmdOneShot( + type: _CmdType.renameStore, + args: { + 'currentStoreName': currentStoreName, + 'newStoreName': newStoreName, + }, + ); + + @override + Future deleteStore({ + required String storeName, + }) => + _sendCmdOneShot( + type: _CmdType.deleteStore, + args: {'storeName': storeName}, + ); + + @override + Future<({double size, int length, int hits, int misses})> getStoreStats({ + required String storeName, + }) async => + (await _sendCmdOneShot( + type: _CmdType.getStoreStats, + args: {'storeName': storeName}, + ))!['stats']; + + @override + Future tileExistsInStore({ + required String storeName, + required String url, + }) async => + (await _sendCmdOneShot( + type: _CmdType.tileExistsInStore, + args: {'storeName': storeName, 'url': url}, + ))!['exists']; + + @override + Future readTile({ + required String url, + String? storeName, + }) async => + (await _sendCmdOneShot( + type: _CmdType.readTile, + args: {'url': url, 'storeName': storeName}, + ))!['tile']; + + @override + Future readLatestTile({ + required String storeName, + }) async => + (await _sendCmdOneShot( + type: _CmdType.readLatestTile, + args: {'storeName': storeName}, + ))!['tile']; + + @override + Future writeTile({ + required String storeName, + required String url, + required Uint8List bytes, + }) => + _sendCmdOneShot( + type: _CmdType.writeTile, + args: {'storeName': storeName, 'url': url, 'bytes': bytes}, + ); + + @override + Future deleteTile({ + required String storeName, + required String url, + }) async => + (await _sendCmdOneShot( + type: _CmdType.deleteTile, + args: {'storeName': storeName, 'url': url}, + ))!['wasOrphan']; + + @override + Future registerHitOrMiss({ + required String storeName, + required bool hit, + }) => + _sendCmdOneShot( + type: _CmdType.registerHitOrMiss, + args: {'storeName': storeName, 'hit': hit}, + ); + + @override + Future removeOldestTilesAboveLimit({ + required String storeName, + required int tilesLimit, + }) 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(); + } + void sendCmdAndComplete() => _rotalResultCompleter!.complete( + _sendCmdOneShot( + type: _CmdType.removeOldestTilesAboveLimit, + args: {'storeName': storeName, 'tilesLimit': tilesLimit}, + ).then((v) => v!['numOrphans']), + ); + + // If the store has changed, failing to reset the batch/queue will mean + // tiles are removed from the wrong store + if (_rotalStore != storeName) { + _rotalStore = storeName; + if (_rotalDebouncer?.isActive ?? false) { + _rotalDebouncer!.cancel(); + sendCmdAndComplete(); + return _rotalResultCompleter!.future; + } + } + + // If the timer is already running, debouncing is required: cancel the + // current timer, and start a new one with a shorter timeout + final isAlreadyActive = _rotalDebouncer?.isActive ?? false; + if (isAlreadyActive) _rotalDebouncer!.cancel(); + _rotalDebouncer = Timer( + Duration(milliseconds: isAlreadyActive ? 500 : 1000), + sendCmdAndComplete, + ); + + return _rotalResultCompleter!.future; + } + + @override + Future removeTilesOlderThan({ + required String storeName, + required DateTime expiry, + }) async => + (await _sendCmdOneShot( + type: _CmdType.removeTilesOlderThan, + args: {'storeName': storeName, 'expiry': expiry}, + ))!['numOrphans']; + + @override + Future> readMetadata({ + required String storeName, + }) async => + (await _sendCmdOneShot( + type: _CmdType.readMetadata, + args: {'storeName': storeName}, + ))!['metadata']; + + @override + Future setMetadata({ + required String storeName, + required String key, + required String value, + }) => + _sendCmdOneShot( + type: _CmdType.setMetadata, + args: {'storeName': storeName, 'key': key, 'value': value}, + ); + + @override + Future setBulkMetadata({ + required String storeName, + required Map kvs, + }) => + _sendCmdOneShot( + type: _CmdType.setBulkMetadata, + args: {'storeName': storeName, 'kvs': kvs}, + ); + + @override + Future removeMetadata({ + required String storeName, + required String key, + }) async => + (await _sendCmdOneShot( + type: _CmdType.removeMetadata, + args: {'storeName': storeName, 'key': key}, + ))!['removedValue']; + + @override + Future resetMetadata({ + required String storeName, + }) => + _sendCmdOneShot( + type: _CmdType.resetMetadata, + args: {'storeName': storeName}, + ); + + @override + Future> listRecoverableRegions() async => + (await _sendCmdOneShot( + type: _CmdType.listRecoverableRegions, + ))!['recoverableRegions']; + + @override + Future getRecoverableRegion({ + required int id, + }) async => + (await _sendCmdOneShot( + type: _CmdType.getRecoverableRegion, + ))!['recoverableRegion']; + + @override + Future cancelRecovery({ + required int id, + }) => + _sendCmdOneShot(type: _CmdType.cancelRecovery, args: {'id': id}); + + @override + Stream watchRecovery({ + required bool triggerImmediately, + }) => + _sendCmdStreamed( + type: _CmdType.watchRecovery, + args: {'triggerImmediately': triggerImmediately}, + ); + + @override + Stream watchStores({ + required List storeNames, + required bool triggerImmediately, + }) => + _sendCmdStreamed( + type: _CmdType.watchStores, + args: { + 'storeNames': storeNames, + 'triggerImmediately': triggerImmediately, + }, + ); + + @override + Future exportStores({ + required List storeNames, + required String path, + }) async { + if (storeNames.isEmpty) { + throw ArgumentError.value(storeNames, 'storeNames', 'must not be empty'); + } + + final type = await FileSystemEntity.type(path); + if (type == FileSystemEntityType.directory) { + throw ImportExportPathNotFile(); + } + + await _sendCmdOneShot( + type: _CmdType.exportStores, + args: {'storeNames': storeNames, 'outputPath': path}, + ); + } + + @override + ImportResult importStores({ + required String path, + required ImportConflictStrategy strategy, + required List? storeNames, + }) { + Stream?> checkTypeAndStartImport() async* { + await _checkImportPathType(path); + yield* _sendCmdStreamed( + type: _CmdType.importStores, + args: {'path': path, 'strategy': strategy, 'stores': storeNames}, + ); + } + + final storesToStates = Completer(); + final complete = Completer(); + + late final StreamSubscription?> listener; + listener = checkTypeAndStartImport().listen( + (evt) { + if (evt!.containsKey('storesToStates')) { + storesToStates.complete(evt['storesToStates']); + } + if (evt.containsKey('complete')) { + complete.complete(evt['complete']); + listener.cancel(); + } + }, + cancelOnError: true, + ); + + return ( + storesToStates: storesToStates.future, + complete: complete.future, + ); + } + + @override + Future> listImportableStores({ + required String path, + }) async { + await _checkImportPathType(path); + + return (await _sendCmdOneShot( + type: _CmdType.listImportableStores, + args: {'path': path}, + ))!['stores']; + } + + Future _checkImportPathType(String path) async { + final type = await FileSystemEntity.type(path); + if (type == FileSystemEntityType.notFound) { + throw ImportPathNotExists(path: path); + } + if (type == FileSystemEntityType.directory) { + throw ImportExportPathNotFile(); + } + } +} diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart new file mode 100644 index 00000000..926d7484 --- /dev/null +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/shared.dart @@ -0,0 +1,76 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../backend.dart'; + +void _sharedWriteSingleTile({ + required Store root, + required String storeName, + required String url, + required Uint8List bytes, +}) { + final tiles = root.box(); + final stores = root.box(); + final rootBox = root.box(); + + final tilesQuery = tiles.query(ObjectBoxTile_.url.equals(url)).build(); + final storeQuery = + stores.query(ObjectBoxStore_.name.equals(storeName)).build(); + + final storesToUpdate = {}; + // If tile exists in this store, just update size, otherwise + // length and size + // Also update size of all related stores + bool didContainAlready = false; + + root.runInTransaction( + TxMode.write, + () { + final existingTile = tilesQuery.findUnique(); + final store = storeQuery.findUnique() ?? + (throw StoreNotExists(storeName: storeName)); + + if (existingTile != null) { + for (final relatedStore in existingTile.stores) { + if (relatedStore.name == storeName) didContainAlready = true; + + storesToUpdate[relatedStore.name] = + (storesToUpdate[relatedStore.name] ?? relatedStore) + ..size += + -existingTile.bytes.lengthInBytes + bytes.lengthInBytes; + } + + rootBox.put( + rootBox.get(1)! + ..size += -existingTile.bytes.lengthInBytes + bytes.lengthInBytes, + mode: PutMode.update, + ); + } else { + rootBox.put( + rootBox.get(1)! + ..length += 1 + ..size += bytes.lengthInBytes, + mode: PutMode.update, + ); + } + + if (!didContainAlready || existingTile == null) { + storesToUpdate[storeName] = store + ..length += 1 + ..size += bytes.lengthInBytes; + } + + tiles.put( + ObjectBoxTile( + url: url, + lastModified: DateTime.timestamp(), + bytes: bytes, + )..stores.addAll({store, ...?existingTile?.stores}), + ); + stores.putMany(storesToUpdate.values.toList(), mode: PutMode.update); + }, + ); + + tilesQuery.close(); + storeQuery.close(); +} 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 new file mode 100644 index 00000000..a469cd2f --- /dev/null +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/cmd_type.dart @@ -0,0 +1,56 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../backend.dart'; + +enum _CmdType { + initialise_, // Only valid as a request + destroy, + realSize, + rootSize, + rootLength, + listStores, + storeExists, + createStore, + resetStore, + renameStore, + deleteStore, + getStoreStats, + tileExistsInStore, + readTile, + readLatestTile, + writeTile, + deleteTile, + registerHitOrMiss, + removeOldestTilesAboveLimit, + removeTilesOlderThan, + readMetadata, + setMetadata, + setBulkMetadata, + removeMetadata, + resetMetadata, + listRecoverableRegions, + getRecoverableRegion, + cancelRecovery, + watchRecovery(hasInternalStreamSub: true), + watchStores(hasInternalStreamSub: true), + exportStores, + importStores(hasInternalStreamSub: false), + listImportableStores, + cancelInternalStreamSub; + + const _CmdType({this.hasInternalStreamSub}); + + /// Whether this command streams multiple results back + /// + /// If `true`, then this command does stream results, and it has an internal + /// [StreamSubscription] that should be cancelled (using + /// [cancelInternalStreamSub]) when it no longer needs to stream results. + /// + /// If `false`, then this command does stream results, but has no stream sub + /// to be cancelled. + /// + /// If `null`, then this command does not stream results, and just returns a + /// single result. + final bool? hasInternalStreamSub; +} 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 new file mode 100644 index 00000000..38dd2db8 --- /dev/null +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/incoming_cmd.dart @@ -0,0 +1,6 @@ +// 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 new file mode 100644 index 00000000..a5134af7 --- /dev/null +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -0,0 +1,1337 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../backend.dart'; + +Future _worker( + ({ + SendPort sendPort, + String rootDirectory, + int maxDatabaseSize, + String? macosApplicationGroup, + RootIsolateToken rootIsolateToken, + }) input, +) async { + //! SETUP !// + + // Setup comms + final receivePort = ReceivePort(); + void sendRes({required int id, Map? data}) => + input.sendPort.send((id: id, data: data)); + + // Enable ObjectBox usage from this background isolate + BackgroundIsolateBinaryMessenger.ensureInitialized(input.rootIsolateToken); + + // Open database, kill self if failed + late final Store root; + try { + root = await openStore( + directory: input.rootDirectory, + maxDBSizeInKB: input.maxDatabaseSize, // Defaults to 10 GB + macosApplicationGroup: input.macosApplicationGroup, + ); + + // If the database is new, create the root statistics object + final rootBox = root.box(); + if (!rootBox.contains(1)) { + rootBox.put(ObjectBoxRoot(length: 0, size: 0), mode: PutMode.insert); + } + } catch (e, s) { + sendRes(id: 0, data: {'error': e, 'stackTrace': s}); + Isolate.exit(); + } + + // Create memory for streamed output subscription storage + final streamedOutputSubscriptions = {}; + + // Respond with comms channel for future cmds + sendRes( + id: 0, + data: {'sendPort': receivePort.sendPort, 'storeReference': root.reference}, + ); + + //! UTIL METHODS !// + + /// Update the root statistics object (ID 0) with the existing values plus + /// the specified values respectively + /// + /// Should be run within a transaction. + /// + /// Specified values may be negative. + /// + /// Handles cases where there is no root statistics object yet. + void updateRootStatistics({int deltaLength = 0, int deltaSize = 0}) => + root.box().put( + root.box().get(1)! + ..length += deltaLength + ..size += deltaSize, + mode: PutMode.update, + ); + + /// Delete the specified tiles from the specified store + /// + /// Note that [tilesQuery] is not closed internally. Ensure it is closed after + /// usage. + /// + /// Note that a transaction is used internally as necessary. + /// + /// Returns the number of orphaned (deleted) tiles. + Future deleteTiles({ + required Query storesQuery, + required Query tilesQuery, + }) async { + final stores = root.box(); + final tiles = root.box(); + + bool hadTilesToUpdate = false; + int rootDeltaSize = 0; + final tilesToRemove = []; + //final tileRelationsToUpdate = >[]; + final storesToUpdate = {}; + + final queriedStores = storesQuery.property(ObjectBoxStore_.name).find(); + if (queriedStores.isEmpty) return 0; + + // For each store, remove it from the tile if requested + // For each store & if removed, update that store's stats + await for (final tile in tilesQuery.stream()) { + tile.stores.removeWhere((store) { + if (!queriedStores.contains(store.name)) return false; + + storesToUpdate[store.name] = (storesToUpdate[store.name] ?? store) + ..length -= 1 + ..size -= tile.bytes.lengthInBytes; + + return true; + }); + + if (tile.stores.isNotEmpty) { + tile.stores.applyToDb(mode: PutMode.update); + hadTilesToUpdate = true; + continue; + } + + rootDeltaSize -= tile.bytes.lengthInBytes; + tilesToRemove.add(tile.id); + } + + if (!hadTilesToUpdate && tilesToRemove.isEmpty) return 0; + + root.runInTransaction( + TxMode.write, + () { + tilesToRemove.forEach(tiles.remove); + + updateRootStatistics( + deltaLength: -tilesToRemove.length, + deltaSize: rootDeltaSize, + ); + + stores.putMany( + storesToUpdate.values.toList(), + mode: PutMode.update, + ); + }, + ); + + return tilesToRemove.length; + } + + /// Verify that the specified file is a valid FMTC format archive, compatible + /// with this ObjectBox backend + /// + /// Note that this method writes to the input file, converting it to a valid + /// database if possible + void verifyImportableArchive(File importFile) { + final ram = importFile.openSync(mode: FileMode.append); + try { + int cursorPos = ram.positionSync() - 1; + ram.setPositionSync(cursorPos); + + // Check for FMTC footer signature ("**FMTC") + const signature = [255, 255, 70, 77, 84, 67]; + for (int i = 5; i >= 0; i--) { + if (signature[i] != ram.readByteSync()) { + throw ImportFileNotFMTCStandard(); + } + ram.setPositionSync(--cursorPos); + } + + // Check for expected backend identifier ("**ObjectBox") + const id = [255, 255, 79, 98, 106, 101, 99, 116, 66, 111, 120]; + for (int i = 10; i >= 0; i--) { + if (id[i] != ram.readByteSync()) { + throw ImportFileNotBackendCompatible(); + } + ram.setPositionSync(--cursorPos); + } + + ram.truncateSync(--cursorPos); + } catch (e) { + ram.closeSync(); + rethrow; + } + ram.closeSync(); + } + + //! MAIN HANDLER !// + + void mainHandler(_IncomingCmd cmd) { + switch (cmd.type) { + case _CmdType.initialise_: + throw UnsupportedError('Invalid operation'); + case _CmdType.destroy: + root.close(); + + if (cmd.args['deleteRoot'] == true) { + if (input.rootDirectory.startsWith(Store.inMemoryPrefix)) { + Store.removeDbFiles(input.rootDirectory); + } else { + Directory(input.rootDirectory).deleteSync(recursive: true); + } + } + + sendRes(id: cmd.id); + + Isolate.exit(); + case _CmdType.realSize: + sendRes( + id: cmd.id, + data: { + 'size': + Store.dbFileSize(input.rootDirectory) / 1024, // Convert to KiB + }, + ); + case _CmdType.rootSize: + sendRes( + id: cmd.id, + data: {'size': root.box().get(1)!.size / 1024}, + ); + case _CmdType.rootLength: + sendRes( + id: cmd.id, + data: {'length': root.box().get(1)!.length}, + ); + case _CmdType.listStores: + final query = root + .box() + .query() + .build() + .property(ObjectBoxStore_.name); + + sendRes(id: cmd.id, data: {'stores': query.find()}); + + query.close(); + case _CmdType.storeExists: + final query = root + .box() + .query( + ObjectBoxStore_.name.equals(cmd.args['storeName']! as String), + ) + .build(); + + sendRes(id: cmd.id, data: {'exists': query.count() == 1}); + + query.close(); + case _CmdType.getStoreStats: + final storeName = cmd.args['storeName']! as String; + + final query = root + .box() + .query(ObjectBoxStore_.name.equals(storeName)) + .build(); + + final store = + query.findUnique() ?? (throw StoreNotExists(storeName: storeName)); + + sendRes( + id: cmd.id, + data: { + 'stats': ( + size: store.size / 1024, // Convert to KiB + length: store.length, + hits: store.hits, + misses: store.misses, + ), + }, + ); + + query.close(); + case _CmdType.createStore: + final storeName = cmd.args['storeName']! as String; + + try { + root.box().put( + ObjectBoxStore( + name: storeName, + length: 0, + size: 0, + hits: 0, + misses: 0, + metadataJson: '{}', + ), + mode: PutMode.insert, + ); + } on UniqueViolationException { + sendRes(id: cmd.id); + break; + } + + sendRes(id: cmd.id); + case _CmdType.resetStore: + final storeName = cmd.args['storeName']! as String; + + final tiles = root.box(); + final stores = root.box(); + + final storeQuery = + stores.query(ObjectBoxStore_.name.equals(storeName)).build(); + + final store = storeQuery.findUnique() ?? + (throw StoreNotExists(storeName: storeName)); + + final tilesQuery = (tiles.query() + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.equals(storeName), + )) + .build(); + + deleteTiles(storesQuery: storeQuery, tilesQuery: tilesQuery).then((_) { + stores.put( + store + ..length = 0 + ..size = 0 + ..hits = 0 + ..misses = 0, + mode: PutMode.update, + ); + + sendRes(id: cmd.id); + + storeQuery.close(); + tilesQuery.close(); + }); + case _CmdType.renameStore: + final currentStoreName = cmd.args['currentStoreName']! as String; + final newStoreName = cmd.args['newStoreName']! as String; + + final stores = root.box(); + + final query = + stores.query(ObjectBoxStore_.name.equals(currentStoreName)).build(); + + root.runInTransaction( + TxMode.write, + () { + final store = query.findUnique() ?? + (throw StoreNotExists(storeName: currentStoreName)); + query.close(); + + stores.put(store..name = newStoreName, mode: PutMode.update); + }, + ); + + sendRes(id: cmd.id); + case _CmdType.deleteStore: + final storeName = cmd.args['storeName']! as String; + + final storesQuery = root + .box() + .query(ObjectBoxStore_.name.equals(storeName)) + .build(); + final tilesQuery = (root.box().query() + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.equals(storeName), + )) + .build(); + + deleteTiles(storesQuery: storesQuery, tilesQuery: tilesQuery).then((_) { + storesQuery.remove(); + + sendRes(id: cmd.id); + + storesQuery.close(); + tilesQuery.close(); + }); + case _CmdType.tileExistsInStore: + final storeName = cmd.args['storeName']! as String; + final url = cmd.args['url']! as String; + + final query = + (root.box().query(ObjectBoxTile_.url.equals(url)) + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.equals(storeName), + )) + .build(); + + sendRes(id: cmd.id, data: {'exists': query.count() == 1}); + + query.close(); + case _CmdType.readTile: + final url = cmd.args['url']! as String; + final storeName = cmd.args['storeName'] as String?; + + final stores = root.box(); + + final queryPart = stores.query(ObjectBoxTile_.url.equals(url)); + final query = storeName == null + ? queryPart.build() + : (queryPart + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.equals(storeName), + )) + .build(); + + sendRes(id: cmd.id, data: {'tile': query.findUnique()}); + + query.close(); + case _CmdType.readLatestTile: + final storeName = cmd.args['storeName']! as String; + + final query = (root + .box() + .query() + .order(ObjectBoxTile_.lastModified, flags: Order.descending) + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.equals(storeName), + )) + .build(); + + sendRes(id: cmd.id, data: {'tile': query.findFirst()}); + + query.close(); + case _CmdType.writeTile: + final storeName = cmd.args['storeName']! as String; + final url = cmd.args['url']! as String; + final bytes = cmd.args['bytes']! as Uint8List; + + _sharedWriteSingleTile( + root: root, + storeName: storeName, + url: url, + bytes: bytes, + ); + + sendRes(id: cmd.id); + case _CmdType.deleteTile: + final storeName = cmd.args['storeName']! as String; + final url = cmd.args['url']! as String; + + final storesQuery = root + .box() + .query(ObjectBoxStore_.name.equals(storeName)) + .build(); + final tilesQuery = root + .box() + .query(ObjectBoxTile_.url.equals(url)) + .build(); + + deleteTiles(storesQuery: storesQuery, tilesQuery: tilesQuery) + .then((orphans) { + sendRes( + id: cmd.id, + data: {'wasOrphan': orphans == 1}, + ); + + storesQuery.close(); + tilesQuery.close(); + }); + case _CmdType.registerHitOrMiss: + final storeName = cmd.args['storeName']! as String; + final hit = cmd.args['hit']! as bool; + + 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 + ..hits += hit ? 1 : 0 + ..misses += hit ? 0 : 1, + ); + }, + ); + + sendRes(id: cmd.id); + case _CmdType.removeOldestTilesAboveLimit: + final storeName = cmd.args['storeName']! as String; + final tilesLimit = cmd.args['tilesLimit']! as int; + + final tilesQuery = (root + .box() + .query() + .order(ObjectBoxTile_.lastModified) + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.equals(storeName), + )) + .build(); + + final storeQuery = root + .box() + .query(ObjectBoxStore_.name.equals(storeName)) + .build(); + + final store = storeQuery.findUnique() ?? + (throw StoreNotExists(storeName: storeName)); + + final numToRemove = store.length - tilesLimit; + + if (numToRemove <= 0) { + sendRes(id: cmd.id, data: {'numOrphans': 0}); + + storeQuery.close(); + tilesQuery.close(); + } else { + tilesQuery.limit = numToRemove; + + deleteTiles(storesQuery: storeQuery, tilesQuery: tilesQuery) + .then((orphans) { + sendRes( + id: cmd.id, + data: {'numOrphans': orphans}, + ); + + storeQuery.close(); + tilesQuery.close(); + }); + } + case _CmdType.removeTilesOlderThan: + final storeName = cmd.args['storeName']! as String; + final expiry = cmd.args['expiry']! as DateTime; + + final storesQuery = root + .box() + .query(ObjectBoxStore_.name.equals(storeName)) + .build(); + final tilesQuery = (root.box().query( + ObjectBoxTile_.lastModified + .greaterThan(expiry.millisecondsSinceEpoch), + )..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.equals(storeName), + )) + .build(); + + deleteTiles(storesQuery: storesQuery, tilesQuery: tilesQuery) + .then((orphans) { + sendRes( + id: cmd.id, + data: {'numOrphans': orphans}, + ); + + storesQuery.close(); + tilesQuery.close(); + }); + + case _CmdType.readMetadata: + final storeName = cmd.args['storeName']! as String; + + final query = root + .box() + .query(ObjectBoxStore_.name.equals(storeName)) + .build(); + + final store = + query.findUnique() ?? (throw StoreNotExists(storeName: storeName)); + + sendRes( + id: cmd.id, + data: { + 'metadata': (jsonDecode(store.metadataJson) as Map) + .cast(), + }, + ); + + 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; + + 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) + ..addAll(kvs), + ), + mode: PutMode.update, + ); + }, + ); + + sendRes(id: cmd.id); + case _CmdType.removeMetadata: + final storeName = cmd.args['storeName']! as String; + final key = cmd.args['key']! as String; + + final stores = root.box(); + + final query = + stores.query(ObjectBoxStore_.name.equals(storeName)).build(); + + sendRes( + id: cmd.id, + data: { + 'removedValue': root.runInTransaction( + TxMode.write, + () { + final store = query.findUnique() ?? + (throw StoreNotExists(storeName: storeName)); + query.close(); + + final metadata = + jsonDecode(store.metadataJson) as Map; + final removedVal = metadata.remove(key) as String?; + + stores.put( + store..metadataJson = jsonEncode(metadata), + mode: PutMode.update, + ); + + return removedVal; + }, + ), + }, + ); + case _CmdType.resetMetadata: + final storeName = cmd.args['storeName']! 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 = '{}', + mode: PutMode.update, + ); + }, + ); + + sendRes(id: cmd.id); + case _CmdType.listRecoverableRegions: + sendRes( + id: cmd.id, + data: { + 'recoverableRegions': root + .box() + .getAll() + .map((r) => r.toRegion()) + .toList(growable: false), + }, + ); + case _CmdType.getRecoverableRegion: + final id = cmd.args['id']! as int; + + sendRes( + id: cmd.id, + data: { + 'recoverableRegion': (root + .box() + .query(ObjectBoxRecovery_.refId.equals(id)) + .build() + ..close()) + .findUnique() + ?.toRegion(), + }, + ); + case _CmdType.cancelRecovery: + final id = cmd.args['id']! as int; + + root + .box() + .query(ObjectBoxRecovery_.refId.equals(id)) + .build() + ..remove() + ..close(); + + sendRes(id: cmd.id); + case _CmdType.watchRecovery: + final triggerImmediately = cmd.args['triggerImmediately']! as bool; + + streamedOutputSubscriptions[cmd.id] = root + .box() + .query() + .watch(triggerImmediately: triggerImmediately) + .listen((_) => sendRes(id: cmd.id, data: {'expectStream': true})); + case _CmdType.watchStores: + final storeNames = cmd.args['storeNames']! as List; + final triggerImmediately = cmd.args['triggerImmediately']! as bool; + + streamedOutputSubscriptions[cmd.id] = root + .box() + .query( + storeNames.isEmpty + ? null + : ObjectBoxStore_.name.oneOf(storeNames), + ) + .watch(triggerImmediately: triggerImmediately) + .listen((_) => sendRes(id: cmd.id, data: {'expectStream': true})); + case _CmdType.cancelInternalStreamSub: + final id = cmd.args['id']! as int; + + if (streamedOutputSubscriptions[id] == null) { + throw StateError( + 'Cannot cancel internal streamed result because none was registered.', + ); + } + + streamedOutputSubscriptions[id]!.cancel(); + streamedOutputSubscriptions.remove(id); + + sendRes(id: cmd.id); + case _CmdType.exportStores: + 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(); + } + + Directory(outputDir).createSync(recursive: true); + + final exportingRoot = Store( + getObjectBoxModel(), + directory: outputDir, + maxDBSizeInKB: input.maxDatabaseSize, // Defaults to 10 GB + macosApplicationGroup: input.macosApplicationGroup, + ); + + final storesQuery = root + .box() + .query(ObjectBoxStore_.name.oneOf(storeNames)) + .build(); + + final tilesQuery = (root.box().query() + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.oneOf(storeNames), + )) + .build(); + + final storesObjectsForRelations = {}; + + final exportingStores = root.runInTransaction( + TxMode.read, + storesQuery.stream, + ); + + exportingRoot + .runInTransaction( + TxMode.write, + () => exportingStores.map( + (exportingStore) { + exportingRoot.box().put( + storesObjectsForRelations[exportingStore.name] = + ObjectBoxStore( + name: exportingStore.name, + length: exportingStore.length, + size: exportingStore.size, + hits: exportingStore.hits, + misses: exportingStore.misses, + metadataJson: exportingStore.metadataJson, + ), + mode: PutMode.insert, + ); + }, + ), + ) + .length + .then( + (numExportedStores) { + if (numExportedStores == 0) throw StateError('Unpossible.'); + + final exportingTiles = root.runInTransaction( + TxMode.read, + tilesQuery.stream, + ); + + exportingRoot + .runInTransaction( + TxMode.write, + () => exportingTiles.map( + (exportingTile) { + exportingRoot.box().put( + ObjectBoxTile( + url: exportingTile.url, + bytes: exportingTile.bytes, + lastModified: exportingTile.lastModified, + )..stores.addAll( + exportingTile.stores + .map( + (s) => storesObjectsForRelations[s.name], + ) + .whereNotNull(), + ), + mode: PutMode.insert, + ); + }, + ), + ) + .length + .then( + (numExportedTiles) { + if (numExportedTiles == 0) { + throw ArgumentError( + 'must include at least one tile in any of the specified ' + 'stores', + 'storeNames', + ); + } + + storesQuery.close(); + tilesQuery.close(); + exportingRoot.close(); + + File(path.join(outputDir, 'lock.mdb')).delete(); + + final ram = File(path.join(outputDir, 'data.mdb')) + .renameSync(outputPath) + .openSync(mode: FileMode.writeOnlyAppend); + try { + ram + ..writeFromSync(List.filled(4, 255)) + ..writeStringSync('ObjectBox') // Backend identifier + ..writeByteSync(255) + ..writeByteSync(255) + ..writeStringSync('FMTC'); // Signature + } finally { + ram.closeSync(); + } + + sendRes(id: cmd.id); + }, + ); + }, + ); + 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 importFile = + File(importPath).copySync(path.join(importDir, 'data.mdb')); + + try { + verifyImportableArchive(importFile); + } catch (e) { + importFile.deleteSync(); + importDirIO.deleteSync(); + rethrow; + } + + final importingRoot = Store( + getObjectBoxModel(), + directory: importDir, + maxDBSizeInKB: input.maxDatabaseSize, + macosApplicationGroup: input.macosApplicationGroup, + ); + + final importingStoresQuery = importingRoot + .box() + .query( + (storesToImport?.isEmpty ?? true) + ? null + : ObjectBoxStore_.name.oneOf(storesToImport!), + ) + .build(); + final specificStoresQuery = root + .box() + .query(ObjectBoxStore_.name.equals('')) + .build(); + + void cleanup() { + importingStoresQuery.close(); + specificStoresQuery.close(); + importingRoot.close(); + + importFile.deleteSync(); + File(path.join(importDir, 'lock.mdb')).deleteSync(); + importDirIO.deleteSync(); + } + + final StoresToStates storesToStates = {}; + // ignore: unnecessary_parenthesis + (switch (strategy) { + ImportConflictStrategy.skip => importingStoresQuery + .stream() + .where( + (importingStore) { + final name = importingStore.name; + final hasConflict = (specificStoresQuery + ..param(ObjectBoxStore_.name).value = name) + .count() == + 1; + storesToStates[name] = ( + name: hasConflict ? null : name, + hadConflict: hasConflict, + ); + + if (hasConflict) return false; + + root.box().put( + ObjectBoxStore( + name: name, + length: importingStore.length, + size: importingStore.size, + hits: 0, + misses: 0, + metadataJson: importingStore.metadataJson, + ), + mode: PutMode.insert, + ); + return true; + }, + ) + .map((s) => s.name) + .toList(), + ImportConflictStrategy.rename => + importingStoresQuery.stream().map((importingStore) { + final name = importingStore.name; + + if ((specificStoresQuery + ..param(ObjectBoxStore_.name).value = name) + .count() == + 0) { + storesToStates[name] = (name: name, hadConflict: false); + root.box().put( + ObjectBoxStore( + name: name, + length: importingStore.length, + size: importingStore.size, + hits: 0, + misses: 0, + metadataJson: importingStore.metadataJson, + ), + mode: PutMode.insert, + ); + return name; + } else { + final newName = '$name [Imported ${DateTime.now()}]'; + storesToStates[name] = (name: newName, hadConflict: true); + final newStore = importingStore..name = newName; + importingRoot + .box() + .put(newStore, mode: PutMode.update); + root.box().put( + ObjectBoxStore( + name: newName, + length: importingStore.length, + size: importingStore.size, + hits: 0, + misses: 0, + metadataJson: importingStore.metadataJson, + ), + mode: PutMode.insert, + ); + return newName; + } + }).toList(), + ImportConflictStrategy.replace || + ImportConflictStrategy.merge => + importingStoresQuery.stream().map( + (importingStore) { + final name = importingStore.name; + + final existingStore = (specificStoresQuery + ..param(ObjectBoxStore_.name).value = name) + .findUnique(); + if (existingStore == null) { + storesToStates[name] = (name: name, hadConflict: false); + root.box().put( + ObjectBoxStore( + name: name, + length: 0, // Will be set when writing tiles + size: 0, // Will be set when writing tiles + hits: 0, + misses: 0, + metadataJson: importingStore.metadataJson, + ), + mode: PutMode.insert, + ); + } else { + storesToStates[name] = (name: name, hadConflict: true); + if (strategy == ImportConflictStrategy.merge) { + root.box().put( + existingStore + ..metadataJson = jsonEncode( + (jsonDecode(existingStore.metadataJson) + as Map) + ..addAll( + jsonDecode(importingStore.metadataJson) + as Map, + ), + ), + mode: PutMode.update, + ); + } + } + + return name; + }, + ).toList(), + }) + .then( + (storesToImport) async { + sendRes( + id: cmd.id, + data: { + 'expectStream': true, + 'storesToStates': storesToStates, + if (storesToImport.isEmpty) 'complete': 0, + }, + ); + if (storesToImport.isEmpty) { + cleanup(); + return; + } + + // At this point: + // * storesToImport should contain only the required IMPORT stores + // * root's stores should be set so that every import store has an + // equivalent with the same name + // It is important never to 'copy' from the import root to the + // in-use root + + final importingTilesQuery = + (importingRoot.box().query() + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.oneOf(storesToImport), + )) + .build(); + final importingTiles = importingTilesQuery.stream(); + + final existingStoresQuery = root + .box() + .query(ObjectBoxStore_.name.equals('')) + .build(); + final existingTilesQuery = root + .box() + .query(ObjectBoxTile_.url.equals('')) + .build(); + + final storesToUpdate = {}; + + int rootDeltaLength = 0; + int rootDeltaSize = 0; + + Iterable convertToExistingStores( + Iterable importingStores, + ) => + importingStores + .where((s) => storesToImport.contains(s.name)) + .map( + (s) => storesToUpdate[s.name] ??= (existingStoresQuery + ..param(ObjectBoxStore_.name).value = s.name) + .findUnique()!, + ); + + if (strategy == ImportConflictStrategy.replace) { + final storesQuery = root + .box() + .query(ObjectBoxStore_.name.oneOf(storesToImport)) + .build(); + final tilesQuery = (root.box().query() + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.oneOf(storesToImport), + )) + .build(); + + await deleteTiles( + storesQuery: storesQuery, + tilesQuery: tilesQuery, + ); + + final importingStoresQuery = importingRoot + .box() + .query(ObjectBoxStore_.name.oneOf(storesToImport)) + .build(); + + final importingStores = importingStoresQuery.find(); + + storesQuery.remove(); + + root.box().putMany( + List.generate( + importingStores.length, + (i) => ObjectBoxStore( + name: importingStores[i].name, + length: importingStores[i].length, + size: importingStores[i].size, + hits: importingStores[i].hits, + misses: importingStores[i].misses, + metadataJson: importingStores[i].metadataJson, + ), + growable: false, + ), + mode: PutMode.insert, + ); + + storesQuery.close(); + tilesQuery.close(); + importingStoresQuery.close(); + } + + final numImportedTiles = await root + .runInTransaction( + TxMode.write, + () => importingTiles.map((importingTile) { + final convertedRelatedStores = + convertToExistingStores(importingTile.stores); + + final existingTile = (existingTilesQuery + ..param(ObjectBoxTile_.url).value = importingTile.url) + .findUnique(); + + if (existingTile == null) { + root.box().put( + ObjectBoxTile( + url: importingTile.url, + bytes: importingTile.bytes, + lastModified: importingTile.lastModified, + )..stores.addAll(convertedRelatedStores), + mode: PutMode.insert, + ); + + // No need to modify store stats, because if tile didn't + // already exist, then was not present in an existing + // store that needs changing, and all importing stores + // are brand new and already contain accurate stats. + // EXCEPT in merge mode - importing stores may not be + // new. + if (strategy == ImportConflictStrategy.merge) { + // No need to worry if it was brand new, we use the + // same logic, treating it as an existing related + // store, because when we created it, we made it + // empty. + for (final convertedRelatedStore + in convertedRelatedStores) { + storesToUpdate[convertedRelatedStore.name] = + (storesToUpdate[convertedRelatedStore.name] ?? + convertedRelatedStore) + ..length += 1 + ..size += importingTile.bytes.lengthInBytes; + } + } + + rootDeltaLength++; + rootDeltaSize += importingTile.bytes.lengthInBytes; + + return 1; + } + + final existingTileIsNewer = existingTile.lastModified + .isAfter(importingTile.lastModified) || + existingTile.lastModified == importingTile.lastModified; + + final relations = { + ...existingTile.stores, + ...convertedRelatedStores, + }; + + root.box().put( + ObjectBoxTile( + url: importingTile.url, + bytes: existingTileIsNewer + ? existingTile.bytes + : importingTile.bytes, + lastModified: existingTileIsNewer + ? existingTile.lastModified + : importingTile.lastModified, + )..stores.addAll(relations), + ); + + if (strategy == ImportConflictStrategy.merge) { + for (final newConvertedRelatedStore + in convertedRelatedStores) { + if (existingTile.stores + .map((e) => e.name) + .contains(newConvertedRelatedStore.name)) { + continue; + } + + storesToUpdate[newConvertedRelatedStore.name] = + (storesToUpdate[newConvertedRelatedStore.name] ?? + newConvertedRelatedStore) + ..length += 1 + ..size += (existingTileIsNewer + ? existingTile + : importingTile) + .bytes + .lengthInBytes; + } + } + + if (existingTileIsNewer) return null; + + for (final existingTileStore in existingTile.stores) { + storesToUpdate[existingTileStore.name] = + (storesToUpdate[existingTileStore.name] ?? + existingTileStore) + ..size += -existingTile.bytes.lengthInBytes + + importingTile.bytes.lengthInBytes; + } + + rootDeltaSize += -existingTile.bytes.lengthInBytes + + importingTile.bytes.lengthInBytes; + + return 1; + }), + ) + .where((e) => e != null) + .length; + + root.box().putMany( + storesToUpdate.values.toList(), + mode: PutMode.update, + ); + + updateRootStatistics( + deltaLength: rootDeltaLength, + deltaSize: rootDeltaSize, + ); + + importingTilesQuery.close(); + existingStoresQuery.close(); + existingTilesQuery.close(); + cleanup(); + + sendRes( + id: cmd.id, + data: { + 'expectStream': true, + 'complete': numImportedTiles, + }, + ); + }, + ); + case _CmdType.listImportableStores: + final importPath = cmd.args['path']! as String; + + final importDir = path.join(input.rootDirectory, 'import_tmp'); + final importDirIO = Directory(importDir)..createSync(); + + final importFile = + File(importPath).copySync(path.join(importDir, 'data.mdb')); + + try { + verifyImportableArchive(importFile); + } catch (e) { + importFile.deleteSync(); + importDirIO.deleteSync(); + rethrow; + } + + final importingRoot = Store( + getObjectBoxModel(), + directory: importDir, + maxDBSizeInKB: input.maxDatabaseSize, // Defaults to 10 GB + macosApplicationGroup: input.macosApplicationGroup, + ); + + sendRes( + id: cmd.id, + data: { + 'stores': importingRoot + .box() + .getAll() + .map((e) => e.name) + .toList(growable: false), + }, + ); + + importingRoot.close(); + + importFile.deleteSync(); + File(path.join(importDir, 'lock.mdb')).deleteSync(); + importDirIO.deleteSync(); + } + } + + //! CMD/COMM RECIEVER !// + + await receivePort.listen((cmd) { + try { + mainHandler(cmd); + } catch (e, s) { + cmd as _IncomingCmd; + + sendRes( + id: cmd.id, + data: { + if (cmd.type.hasInternalStreamSub != null) 'expectStream': true, + 'error': e, + 'stackTrace': s, + }, + ); + } + }).asFuture(); +} 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 new file mode 100644 index 00000000..297b2580 --- /dev/null +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/thread_safe.dart @@ -0,0 +1,190 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../backend.dart'; + +class _ObjectBoxBackendThreadSafeImpl implements FMTCBackendInternalThreadSafe { + _ObjectBoxBackendThreadSafeImpl._({ + required this.storeReference, + }); + + @override + String get friendlyIdentifier => 'ObjectBox'; + + final ByteData storeReference; + Store get expectInitialisedRoot => _root ?? (throw RootUnavailable()); + Store? _root; + + @override + void initialise() { + if (_root != null) throw RootAlreadyInitialised(); + _root = Store.fromReference(getObjectBoxModel(), storeReference); + } + + @override + void uninitialise() { + expectInitialisedRoot.close(); + _root = null; + } + + @override + _ObjectBoxBackendThreadSafeImpl duplicate() => + _ObjectBoxBackendThreadSafeImpl._(storeReference: storeReference); + + @override + Future readTile({ + required String url, + String? storeName, + }) async { + final stores = expectInitialisedRoot.box(); + + final query = storeName == null + ? stores.query(ObjectBoxTile_.url.equals(url)).build() + : (stores.query(ObjectBoxTile_.url.equals(url)) + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.equals(storeName), + )) + .build(); + + final tile = query.findUnique(); + + query.close(); + + return tile; + } + + @override + void writeTile({ + required String storeName, + required String url, + required Uint8List bytes, + }) => + _sharedWriteSingleTile( + root: expectInitialisedRoot, + storeName: storeName, + url: url, + bytes: bytes, + ); + + @override + void writeTiles({ + required String storeName, + required List urls, + required List bytess, + }) { + expectInitialisedRoot; + + final tiles = _root!.box(); + final stores = _root!.box(); + final rootBox = _root!.box(); + + final tilesQuery = tiles.query(ObjectBoxTile_.url.equals('')).build(); + final storeQuery = + stores.query(ObjectBoxStore_.name.equals(storeName)).build(); + + final storesToUpdate = {}; + + _root!.runInTransaction( + TxMode.write, + () { + final rootData = rootBox.get(1)!; + final store = storeQuery.findUnique() ?? + (throw StoreNotExists(storeName: storeName)); + + for (int i = 0; i <= urls.length - 1; i++) { + final url = urls[i]; + final bytes = bytess[i]; + + final existingTile = + (tilesQuery..param(ObjectBoxTile_.url).value = url).findUnique(); + + // If tile exists in this store, just update size, otherwise + // length and size + // Also update size of all related stores + bool didContainAlready = false; + + if (existingTile != null) { + for (final relatedStore in existingTile.stores) { + if (relatedStore.name == storeName) didContainAlready = true; + + storesToUpdate[relatedStore.name] = + (storesToUpdate[relatedStore.name] ?? relatedStore) + ..size += + -existingTile.bytes.lengthInBytes + bytes.lengthInBytes; + } + + rootData.size += + -existingTile.bytes.lengthInBytes + bytes.lengthInBytes; + } else { + rootData + ..length += 1 + ..size += bytes.lengthInBytes; + } + + if (!didContainAlready || existingTile == null) { + storesToUpdate[storeName] = store + ..length += 1 + ..size += bytes.lengthInBytes; + } + + tiles.put( + ObjectBoxTile( + url: url, + lastModified: DateTime.timestamp(), + bytes: bytes, + )..stores.addAll({store, ...?existingTile?.stores}), + ); + } + + rootBox.put(rootData, mode: PutMode.update); + stores.putMany(storesToUpdate.values.toList(), mode: PutMode.update); + }, + ); + + tilesQuery.close(); + storeQuery.close(); + } + + @override + void startRecovery({ + required int id, + required String storeName, + required DownloadableRegion region, + required int endTile, + }) => + expectInitialisedRoot.box().put( + ObjectBoxRecovery.fromRegion( + refId: id, + storeName: storeName, + region: region, + endTile: endTile, + ), + mode: PutMode.insert, + ); + + @override + void updateRecovery({ + required int id, + required int newStartTile, + }) { + expectInitialisedRoot; + + final existingRecoveryQuery = _root! + .box() + .query(ObjectBoxRecovery_.refId.equals(id)) + .build(); + + _root!.runInTransaction( + TxMode.write, + () { + _root!.box().put( + existingRecoveryQuery.findUnique()!..startTile = newStartTile, + mode: PutMode.update, + ); + }, + ); + + existingRecoveryQuery.close(); + } +} diff --git a/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json b/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json new file mode 100644 index 00000000..d8e27ba4 --- /dev/null +++ b/lib/src/backend/impls/objectbox/models/generated/objectbox-model.json @@ -0,0 +1,243 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:5472631385587455945", + "lastPropertyId": "21:3590067577930145922", + "name": "ObjectBoxRecovery", + "properties": [ + { + "id": "1:3769282896877713230", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:2496811483091029921", + "name": "refId", + "type": 6, + "flags": 40, + "indexId": "1:1036386105099927432" + }, + { + "id": "3:3612512640999075849", + "name": "storeName", + "type": 9 + }, + { + "id": "4:1095455913099058361", + "name": "creationTime", + "type": 10 + }, + { + "id": "5:1138350672456876624", + "name": "minZoom", + "type": 6 + }, + { + "id": "6:9040433791555820529", + "name": "maxZoom", + "type": 6 + }, + { + "id": "7:6819230045021667310", + "name": "startTile", + "type": 6 + }, + { + "id": "8:8185724925875119436", + "name": "endTile", + "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 + } + ], + "relations": [] + }, + { + "id": "2:632249766926720928", + "lastPropertyId": "7:7028109958959828879", + "name": "ObjectBoxStore", + "properties": [ + { + "id": "1:1672655555406818874", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:1060752758288526798", + "name": "name", + "type": 9, + "flags": 2080, + "indexId": "2:5602852847672696920" + }, + { + "id": "3:7375048950056890678", + "name": "length", + "type": 6 + }, + { + "id": "4:7781853256122686511", + "name": "size", + "type": 6 + }, + { + "id": "5:3183925806131180531", + "name": "hits", + "type": 6 + }, + { + "id": "6:6484030110235711573", + "name": "misses", + "type": 6 + }, + { + "id": "7:7028109958959828879", + "name": "metadataJson", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "3:8691708694767276679", + "lastPropertyId": "4:1172878417733380836", + "name": "ObjectBoxTile", + "properties": [ + { + "id": "1:5356545328183635928", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:4115905667778721807", + "name": "url", + "type": 9, + "flags": 34848, + "indexId": "3:4361441212367179043" + }, + { + "id": "3:7508139234299399524", + "name": "bytes", + "type": 23 + }, + { + "id": "4:1172878417733380836", + "name": "lastModified", + "type": 10, + "flags": 8, + "indexId": "4:4857742396480146668" + } + ], + "relations": [ + { + "id": "1:7496298295217061586", + "name": "stores", + "targetId": "2:632249766926720928" + } + ] + }, + { + "id": "4:8718814737097934474", + "lastPropertyId": "3:6574336219794969200", + "name": "ObjectBoxRoot", + "properties": [ + { + "id": "1:3527394784453371799", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:2833017356902860570", + "name": "length", + "type": 6 + }, + { + "id": "3:6574336219794969200", + "name": "size", + "type": 6 + } + ], + "relations": [] + } + ], + "lastEntityId": "4:8718814737097934474", + "lastIndexId": "4:4857742396480146668", + "lastRelationId": "1:7496298295217061586", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [], + "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 new file mode 100644 index 00000000..34e04171 --- /dev/null +++ b/lib/src/backend/impls/objectbox/models/generated/objectbox.g.dart @@ -0,0 +1,699 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// This code was generated by ObjectBox. To update it run the generator again: +// With a Flutter package, run `flutter pub run build_runner build`. +// With a Dart package, run `dart run build_runner build`. +// See also https://docs.objectbox.io/getting-started#generate-objectbox-code + +// ignore_for_file: camel_case_types, depend_on_referenced_packages +// coverage:ignore-file + +import 'dart:typed_data'; + +import 'package:flat_buffers/flat_buffers.dart' as fb; +import 'package:objectbox/internal.dart' + as obx_int; // generated code can access "internal" functionality +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/root.dart'; +import '../../../../../../src/backend/impls/objectbox/models/src/store.dart'; +import '../../../../../../src/backend/impls/objectbox/models/src/tile.dart'; + +export 'package:objectbox/objectbox.dart'; // so that callers only have to import this file + +final _entities = [ + obx_int.ModelEntity( + id: const obx_int.IdUid(1, 5472631385587455945), + name: 'ObjectBoxRecovery', + lastPropertyId: const obx_int.IdUid(21, 3590067577930145922), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 3769282896877713230), + name: 'id', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 2496811483091029921), + name: 'refId', + type: 6, + flags: 40, + indexId: const obx_int.IdUid(1, 1036386105099927432)), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 3612512640999075849), + name: 'storeName', + type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 1095455913099058361), + name: 'creationTime', + type: 10, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 1138350672456876624), + name: 'minZoom', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 9040433791555820529), + name: 'maxZoom', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 6819230045021667310), + name: 'startTile', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 8185724925875119436), + name: 'endTile', + 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) + ], + relations: [], + backlinks: []), + obx_int.ModelEntity( + id: const obx_int.IdUid(2, 632249766926720928), + name: 'ObjectBoxStore', + lastPropertyId: const obx_int.IdUid(7, 7028109958959828879), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 1672655555406818874), + name: 'id', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 1060752758288526798), + name: 'name', + type: 9, + flags: 2080, + indexId: const obx_int.IdUid(2, 5602852847672696920)), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 7375048950056890678), + name: 'length', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 7781853256122686511), + name: 'size', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 3183925806131180531), + name: 'hits', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 6484030110235711573), + name: 'misses', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 7028109958959828879), + name: 'metadataJson', + type: 9, + flags: 0) + ], + relations: [], + backlinks: [ + obx_int.ModelBacklink( + name: 'tiles', srcEntity: 'ObjectBoxTile', srcField: 'stores') + ]), + obx_int.ModelEntity( + id: const obx_int.IdUid(3, 8691708694767276679), + name: 'ObjectBoxTile', + lastPropertyId: const obx_int.IdUid(4, 1172878417733380836), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 5356545328183635928), + name: 'id', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 4115905667778721807), + name: 'url', + type: 9, + flags: 34848, + indexId: const obx_int.IdUid(3, 4361441212367179043)), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 7508139234299399524), + name: 'bytes', + type: 23, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 1172878417733380836), + name: 'lastModified', + type: 10, + flags: 8, + indexId: const obx_int.IdUid(4, 4857742396480146668)) + ], + relations: [ + obx_int.ModelRelation( + id: const obx_int.IdUid(1, 7496298295217061586), + name: 'stores', + targetId: const obx_int.IdUid(2, 632249766926720928)) + ], + backlinks: []), + obx_int.ModelEntity( + id: const obx_int.IdUid(4, 8718814737097934474), + name: 'ObjectBoxRoot', + lastPropertyId: const obx_int.IdUid(3, 6574336219794969200), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 3527394784453371799), + name: 'id', + type: 6, + flags: 1), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 2833017356902860570), + name: 'length', + type: 6, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 6574336219794969200), + name: 'size', + type: 6, + flags: 0) + ], + relations: [], + backlinks: []) +]; + +/// Shortcut for [Store.new] that passes [getObjectBoxModel] and for Flutter +/// apps by default a [directory] using `defaultStoreDirectory()` from the +/// ObjectBox Flutter library. +/// +/// Note: for desktop apps it is recommended to specify a unique [directory]. +/// +/// See [Store.new] for an explanation of all parameters. +/// +/// For Flutter apps, also calls `loadObjectBoxLibraryAndroidCompat()` from +/// the ObjectBox Flutter library to fix loading the native ObjectBox library +/// on Android 6 and older. +Future openStore( + {String? directory, + int? maxDBSizeInKB, + int? maxDataSizeInKB, + int? fileMode, + int? maxReaders, + bool queriesCaseSensitiveDefault = true, + String? macosApplicationGroup}) async { + await loadObjectBoxLibraryAndroidCompat(); + return obx.Store(getObjectBoxModel(), + directory: directory ?? (await defaultStoreDirectory()).path, + maxDBSizeInKB: maxDBSizeInKB, + maxDataSizeInKB: maxDataSizeInKB, + fileMode: fileMode, + maxReaders: maxReaders, + queriesCaseSensitiveDefault: queriesCaseSensitiveDefault, + macosApplicationGroup: macosApplicationGroup); +} + +/// Returns the ObjectBox model definition for this project for use with +/// [Store.new]. +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), + lastSequenceId: const obx_int.IdUid(0, 0), + retiredEntityUids: const [], + retiredIndexUids: const [], + retiredPropertyUids: const [], + retiredRelationUids: const [], + modelVersion: 5, + modelVersionParserMinimum: 5, + version: 1); + + final bindings = { + ObjectBoxRecovery: obx_int.EntityDefinition( + model: _entities[0], + toOneRelations: (ObjectBoxRecovery object) => [], + toManyRelations: (ObjectBoxRecovery object) => {}, + getId: (ObjectBoxRecovery object) => object.id, + setId: (ObjectBoxRecovery object, int id) { + object.id = id; + }, + 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.addInt64(0, object.id); + fbb.addInt64(1, object.refId); + fbb.addOffset(2, storeNameOffset); + fbb.addInt64(3, object.creationTime.millisecondsSinceEpoch); + fbb.addInt64(4, object.minZoom); + 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.finish(fbb.endTable()); + return object.id; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final refIdParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 6, 0); + final storeNameParam = const fb.StringReader(asciiOptimization: true) + .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 = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 14, 0); + final startTileParam = + 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 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) + ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }), + ObjectBoxStore: obx_int.EntityDefinition( + model: _entities[1], + toOneRelations: (ObjectBoxStore object) => [], + toManyRelations: (ObjectBoxStore object) => { + obx_int.RelInfo.toManyBacklink(1, object.id): + object.tiles + }, + getId: (ObjectBoxStore object) => object.id, + setId: (ObjectBoxStore object, int id) { + object.id = id; + }, + objectToFB: (ObjectBoxStore object, fb.Builder fbb) { + final nameOffset = fbb.writeString(object.name); + final metadataJsonOffset = fbb.writeString(object.metadataJson); + fbb.startTable(8); + fbb.addInt64(0, object.id); + fbb.addOffset(1, nameOffset); + fbb.addInt64(2, object.length); + fbb.addInt64(3, object.size); + fbb.addInt64(4, object.hits); + fbb.addInt64(5, object.misses); + fbb.addOffset(6, metadataJsonOffset); + fbb.finish(fbb.endTable()); + return object.id; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final nameParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''); + final lengthParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 8, 0); + final sizeParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 10, 0); + final hitsParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 12, 0); + final missesParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 14, 0); + final metadataJsonParam = + const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 16, ''); + final object = ObjectBoxStore( + name: nameParam, + length: lengthParam, + size: sizeParam, + hits: hitsParam, + misses: missesParam, + metadataJson: metadataJsonParam) + ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + obx_int.InternalToManyAccess.setRelInfo( + object.tiles, + store, + obx_int.RelInfo.toManyBacklink(1, object.id)); + return object; + }), + ObjectBoxTile: obx_int.EntityDefinition( + model: _entities[2], + toOneRelations: (ObjectBoxTile object) => [], + toManyRelations: (ObjectBoxTile object) => { + obx_int.RelInfo.toMany(1, object.id): object.stores + }, + getId: (ObjectBoxTile object) => object.id, + setId: (ObjectBoxTile object, int id) { + object.id = id; + }, + objectToFB: (ObjectBoxTile object, fb.Builder fbb) { + final urlOffset = fbb.writeString(object.url); + final bytesOffset = fbb.writeListInt8(object.bytes); + fbb.startTable(5); + fbb.addInt64(0, object.id); + fbb.addOffset(1, urlOffset); + fbb.addOffset(2, bytesOffset); + fbb.addInt64(3, object.lastModified.millisecondsSinceEpoch); + fbb.finish(fbb.endTable()); + return object.id; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final urlParam = const fb.StringReader(asciiOptimization: true) + .vTableGet(buffer, rootOffset, 6, ''); + final bytesParam = const fb.Uint8ListReader(lazy: false) + .vTableGet(buffer, rootOffset, 8, Uint8List(0)) as Uint8List; + final lastModifiedParam = DateTime.fromMillisecondsSinceEpoch( + const fb.Int64Reader().vTableGet(buffer, rootOffset, 10, 0)); + final object = ObjectBoxTile( + url: urlParam, bytes: bytesParam, lastModified: lastModifiedParam) + ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + obx_int.InternalToManyAccess.setRelInfo(object.stores, + store, obx_int.RelInfo.toMany(1, object.id)); + return object; + }), + ObjectBoxRoot: obx_int.EntityDefinition( + model: _entities[3], + toOneRelations: (ObjectBoxRoot object) => [], + toManyRelations: (ObjectBoxRoot object) => {}, + getId: (ObjectBoxRoot object) => object.id, + setId: (ObjectBoxRoot object, int id) { + object.id = id; + }, + objectToFB: (ObjectBoxRoot object, fb.Builder fbb) { + fbb.startTable(4); + fbb.addInt64(0, object.id); + fbb.addInt64(1, object.length); + fbb.addInt64(2, object.size); + fbb.finish(fbb.endTable()); + return object.id; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final lengthParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 6, 0); + final sizeParam = + const fb.Int64Reader().vTableGet(buffer, rootOffset, 8, 0); + final object = ObjectBoxRoot(length: lengthParam, size: sizeParam) + ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }) + }; + + return obx_int.ModelDefinition(model, bindings); +} + +/// [ObjectBoxRecovery] entity fields to define ObjectBox queries. +class ObjectBoxRecovery_ { + /// see [ObjectBoxRecovery.id] + static final id = + obx.QueryIntegerProperty(_entities[0].properties[0]); + + /// see [ObjectBoxRecovery.refId] + static final refId = + obx.QueryIntegerProperty(_entities[0].properties[1]); + + /// see [ObjectBoxRecovery.storeName] + static final storeName = + obx.QueryStringProperty(_entities[0].properties[2]); + + /// see [ObjectBoxRecovery.creationTime] + static final creationTime = + obx.QueryDateProperty(_entities[0].properties[3]); + + /// see [ObjectBoxRecovery.minZoom] + static final minZoom = + obx.QueryIntegerProperty(_entities[0].properties[4]); + + /// see [ObjectBoxRecovery.maxZoom] + static final maxZoom = + obx.QueryIntegerProperty(_entities[0].properties[5]); + + /// see [ObjectBoxRecovery.startTile] + static final startTile = + obx.QueryIntegerProperty(_entities[0].properties[6]); + + /// see [ObjectBoxRecovery.endTile] + 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]); +} + +/// [ObjectBoxStore] entity fields to define ObjectBox queries. +class ObjectBoxStore_ { + /// see [ObjectBoxStore.id] + static final id = + obx.QueryIntegerProperty(_entities[1].properties[0]); + + /// see [ObjectBoxStore.name] + static final name = + obx.QueryStringProperty(_entities[1].properties[1]); + + /// see [ObjectBoxStore.length] + static final length = + obx.QueryIntegerProperty(_entities[1].properties[2]); + + /// see [ObjectBoxStore.size] + static final size = + obx.QueryIntegerProperty(_entities[1].properties[3]); + + /// see [ObjectBoxStore.hits] + static final hits = + obx.QueryIntegerProperty(_entities[1].properties[4]); + + /// see [ObjectBoxStore.misses] + static final misses = + obx.QueryIntegerProperty(_entities[1].properties[5]); + + /// see [ObjectBoxStore.metadataJson] + static final metadataJson = + obx.QueryStringProperty(_entities[1].properties[6]); +} + +/// [ObjectBoxTile] entity fields to define ObjectBox queries. +class ObjectBoxTile_ { + /// see [ObjectBoxTile.id] + static final id = + obx.QueryIntegerProperty(_entities[2].properties[0]); + + /// see [ObjectBoxTile.url] + static final url = + obx.QueryStringProperty(_entities[2].properties[1]); + + /// see [ObjectBoxTile.bytes] + static final bytes = + obx.QueryByteVectorProperty(_entities[2].properties[2]); + + /// see [ObjectBoxTile.lastModified] + static final lastModified = + obx.QueryDateProperty(_entities[2].properties[3]); + + /// see [ObjectBoxTile.stores] + static final stores = obx.QueryRelationToMany( + _entities[2].relations[0]); +} + +/// [ObjectBoxRoot] entity fields to define ObjectBox queries. +class ObjectBoxRoot_ { + /// see [ObjectBoxRoot.id] + static final id = + obx.QueryIntegerProperty(_entities[3].properties[0]); + + /// see [ObjectBoxRoot.length] + static final length = + obx.QueryIntegerProperty(_entities[3].properties[1]); + + /// see [ObjectBoxRoot.size] + static final size = + obx.QueryIntegerProperty(_entities[3].properties[2]); +} diff --git a/lib/src/backend/impls/objectbox/models/src/recovery.dart b/lib/src/backend/impls/objectbox/models/src/recovery.dart new file mode 100644 index 00000000..603bc5a1 --- /dev/null +++ b/lib/src/backend/impls/objectbox/models/src/recovery.dart @@ -0,0 +1,227 @@ +// 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'; + +/// Represents a [RecoveredRegion] in ObjectBox +@Entity() +base class ObjectBoxRecovery { + /// Create a raw representation of a [RecoveredRegion] in ObjectBox + /// + /// Prefer using [ObjectBoxRecovery.fromRegion]. + 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, + }); + + /// Create a raw representation of a [RecoveredRegion] in ObjectBox from a + /// [DownloadableRegion] + ObjectBoxRecovery.fromRegion({ + required this.refId, + required this.storeName, + required DownloadableRegion region, + required this.endTile, + }) : 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; + + /// ObjectBox ID + /// + /// Not to be confused with [refId]. + @Id() + @internal + int id = 0; + + /// Corresponds to [RecoveredRegion.id] + @Index() + @Unique() + int refId; + + /// Corresponds to [RecoveredRegion.storeName] + String storeName; + + /// The timestamp of when this object was created/stored + @Property(type: PropertyType.date) + DateTime creationTime; + + /// Corresponds to [RecoveredRegion.minZoom] & [DownloadableRegion.minZoom] + int minZoom; + + /// Corresponds to [RecoveredRegion.maxZoom] & [DownloadableRegion.maxZoom] + int maxZoom; + + /// Corresponds to [RecoveredRegion.start] & [DownloadableRegion.start] + 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; + + /// Corresponds to [RecoveredRegion.line] ([CustomPolygonRegion.outline]) + List? customPolygonLngs; + + /// 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, + ); +} diff --git a/lib/src/backend/impls/objectbox/models/src/root.dart b/lib/src/backend/impls/objectbox/models/src/root.dart new file mode 100644 index 00000000..210c32a4 --- /dev/null +++ b/lib/src/backend/impls/objectbox/models/src/root.dart @@ -0,0 +1,24 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'package:objectbox/objectbox.dart'; + +/// Cache for root-level statistics in ObjectBox +@Entity() +class ObjectBoxRoot { + /// Create a new cache for root-level statistics in ObjectBox + ObjectBoxRoot({ + required this.length, + required this.size, + }); + + /// ObjectBox ID + @Id() + int id = 0; + + /// Total number of tiles + int length; + + /// Total size (in bytes) of all tiles + int size; +} diff --git a/lib/src/backend/impls/objectbox/models/src/store.dart b/lib/src/backend/impls/objectbox/models/src/store.dart new file mode 100644 index 00000000..690f6472 --- /dev/null +++ b/lib/src/backend/impls/objectbox/models/src/store.dart @@ -0,0 +1,67 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'package:objectbox/objectbox.dart'; + +import 'tile.dart'; + +/// Cache for store-level statistics & storage for metadata, referenced by +/// unique name, in ObjectBox +/// +/// Only [name] is used for equality. +@Entity() +class ObjectBoxStore { + /// Create a cache for store-level statistics & storage for metadata, + /// referenced by unique name, in ObjectBox + ObjectBoxStore({ + required this.name, + required this.length, + required this.size, + required this.hits, + required this.misses, + required this.metadataJson, + }); + + /// ObjectBox ID + @Id() + int id = 0; + + /// Human-readable name of the store + /// + /// Only this property is used for equality. + @Index() + @Unique() + String name; + + /// Relation to all tiles that belong to this store + @Index() + @Backlink('stores') + final tiles = ToMany(); + + /// Number of tiles + int length; + + /// Size (in bytes) of all tiles + int size; + + /// Number of cache hits (successful retrievals) from this store only + int hits; + + /// Number of cache misses (unsuccessful retrievals) from this store only + int misses; + + /// Storage for metadata in JSON format + /// + /// 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/objectbox/models/src/tile.dart b/lib/src/backend/impls/objectbox/models/src/tile.dart new file mode 100644 index 00000000..824e46ff --- /dev/null +++ b/lib/src/backend/impls/objectbox/models/src/tile.dart @@ -0,0 +1,41 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'dart:typed_data'; + +import 'package:objectbox/objectbox.dart'; + +import '../../../../interfaces/models.dart'; +import 'store.dart'; + +/// ObjectBox-specific implementation of [BackendTile] +@Entity() +base class ObjectBoxTile extends BackendTile { + /// Create an ObjectBox-specific implementation of [BackendTile] + ObjectBoxTile({ + required this.url, + required this.bytes, + required this.lastModified, + }); + + /// ObjectBox ID + @Id() + int id = 0; + + @override + @Index() + @Unique(onConflict: ConflictStrategy.replace) + String url; + + @override + Uint8List bytes; + + @override + @Index() + @Property(type: PropertyType.date) + DateTime lastModified; + + /// Relation to all stores that this tile belongs to + @Index() + final stores = ToMany(); +} diff --git a/lib/src/backend/interfaces/backend/backend.dart b/lib/src/backend/interfaces/backend/backend.dart new file mode 100644 index 00000000..43638249 --- /dev/null +++ b/lib/src/backend/interfaces/backend/backend.dart @@ -0,0 +1,57 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'dart:async'; + +import '../../export_external.dart'; +import '../../export_internal.dart'; + +/// {@template fmtc.backend.backend} +/// An abstract interface that FMTC will use to communicate with a storage +/// 'backend' (usually one root) +/// +/// --- +/// +/// For implementers: +/// +/// See also [FMTCBackendInternal] and [FMTCBackendInternalThreadSafe], which +/// have the actual method signatures. This is provided as a public means to +/// initialise and uninitialise the backend. +/// +/// When creating a custom implementation, follow the same pattern as the +/// built-in ObjectBox backend ([FMTCObjectBoxBackend]). +/// +/// [initialise] & [uninitialise]'s implementations should redirect to an +/// implementation in a [FMTCBackendInternal], where the setter of +/// [FMTCBackendAccess.internal] and [FMTCBackendAccessThreadSafe.internal] may +/// be accessed - see documentation on [FMTCBackendAccess] for more +/// information. +/// {@endtemplate} +abstract interface class FMTCBackend { + /// {@macro fmtc.backend.backend} + /// + /// This constructor does not initialise this backend, also invoke + /// [initialise]. + const FMTCBackend(); + + /// {@template fmtc.backend.initialise} + /// Initialise this backend, and create the root + /// + /// Prefer to leave [rootDirectory] as null, which will use + /// `getApplicationDocumentsDirectory()`. Alternatively, pass a custom + /// directory - it is recommended to not use a typical cache directory, as the + /// OS can clear these without notice at any time. + /// {@endtemplate} + Future initialise({ + String? rootDirectory, + }); + + /// {@template fmtc.backend.uninitialise} + /// Uninitialise this backend, and release whatever resources it is consuming + /// + /// If [deleteRoot] is `true`, then the root will be permanently deleted. + /// {@endtemplate} + Future uninitialise({ + bool deleteRoot = false, + }); +} diff --git a/lib/src/backend/interfaces/backend/internal.dart b/lib/src/backend/interfaces/backend/internal.dart new file mode 100644 index 00000000..2935697e --- /dev/null +++ b/lib/src/backend/interfaces/backend/internal.dart @@ -0,0 +1,343 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../../../flutter_map_tile_caching.dart'; +import '../../export_internal.dart'; + +/// An abstract interface that FMTC will use to communicate with a storage +/// 'backend' (usually one root), from a 'normal' thread (likely the UI thread) +/// +/// 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 be set in [FMTCBackendAccess] when ready to use, and unset when not. +/// See documentation on that class for more information. +/// +/// Methods with a doc template in the doc string are for 'direct' public +/// invocation. +/// +/// See [FMTCBackend] for more information. +abstract interface class FMTCBackendInternal + with FMTCBackendAccess, FMTCBackendAccessThreadSafe { + const FMTCBackendInternal._(); + + /// Generic description/name of this backend + abstract final String friendlyIdentifier; + + /// {@template fmtc.backend.realSize} + /// Retrieve the actual total size of the database in KiBs + /// + /// Should include 'unused' space, 'calculation' space, overheads, etc. May be + /// much larger than `rootSize` in some backends. + /// {@endtemplate} + Future realSize(); + + /// {@template fmtc.backend.rootSize} + /// Retrieve the total number of KiBs of all tiles' bytes (not 'real total' + /// size) from all stores + /// + /// Does not include any storage used by metadata or database overheads, as in + /// `realSize`. + /// {@endtemplate} + Future rootSize(); + + /// {@template fmtc.backend.rootLength} + /// Retrieve the total number of tiles in all stores + /// {@endtemplate} + Future rootLength(); + + /// {@template fmtc.backend.listStores} + /// List all the available stores + /// {@endtemplate} + Future> listStores(); + + /// {@template fmtc.backend.storeExists} + /// Check whether the specified store currently exists + /// {@endtemplate} + Future storeExists({ + required String storeName, + }); + + /// {@template fmtc.backend.createStore} + /// Create a new store with the specified name + /// + /// Does nothing if the store already exists. + /// {@endtemplate} + Future createStore({ + required String storeName, + }); + + /// {@template fmtc.backend.deleteStore} + /// Delete the specified store + /// + /// > [!WARNING] + /// > This operation cannot be undone! Ensure you confirm with the user that + /// > this action is expected. + /// + /// Does nothing if the store does not already exist. + /// {@endtemplate} + Future deleteStore({ + required String storeName, + }); + + /// {@template fmtc.backend.resetStore} + /// Remove all the tiles from within the specified store + /// + /// Also resets the hits & misses stats. Does not reset any associated + /// metadata. + /// + /// > [!WARNING] + /// > This operation cannot be undone! Ensure you confirm with the user that + /// > this action is expected. + /// + /// Does nothing if the store does not already exist. + /// {@endtemplate} + Future resetStore({ + required String storeName, + }); + + /// {@template fmtc.backend.renameStore} + /// Change the name of the specified store to the specified new store name + /// {@endtemplate} + Future renameStore({ + required String currentStoreName, + required String newStoreName, + }); + + /// {@template fmtc.backend.getStoreStats} + /// Retrieve the following statistics about the specified store (all available): + /// + /// * `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 + /// {@endtemplate} + Future<({double size, int length, int hits, int misses})> getStoreStats({ + required String storeName, + }); + + /// Check whether the specified tile exists in the specified store + Future tileExistsInStore({ + required String storeName, + required String url, + }); + + /// Retrieve a raw tile by the specified URL + /// + /// If [storeName] is specified, the tile will be limited to the specified + /// store - if it exists in another store, it will not be returned. + Future readTile({ + required String url, + String? storeName, + }); + + /// {@template fmtc.backend.readLatestTile} + /// Retrieve the tile most recently modified in the specified store, if any + /// tiles exist + /// {@endtemplate} + Future readLatestTile({ + required String storeName, + }); + + /// Create or update a tile (given a [url] and its [bytes]) in the specified + /// store + Future writeTile({ + required String storeName, + required String url, + required Uint8List bytes, + }); + + /// Remove the tile from the specified store, deleting it if was orphaned + /// + /// As tiles can belong to multiple stores, a tile cannot be safely 'truly' + /// deleted unless it does not belong to any other stores (it was an orphan). + /// A tile that is not an orphan will just be 'removed' from the specified + /// store. + /// + /// Returns: + /// * `null` : if there was no existing tile + /// * `true` : if the tile itself could be deleted (it was orphaned) + /// * `false`: if the tile still belonged to at least one other store + @visibleForTesting + Future deleteTile({ + required String storeName, + required String url, + }); + + /// Register a cache hit or miss on the specified store + Future registerHitOrMiss({ + required String storeName, + required bool hit, + }); + + /// Remove tiles in excess of the specified limit from the specified store, + /// oldest 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)). + /// + /// Throws [RootUnavailable] if the root is uninitialised whilst the + /// debouncing mechanism is running. + Future removeOldestTilesAboveLimit({ + required String storeName, + required int tilesLimit, + }); + + /// {@template fmtc.backend.removeTilesOlderThan} + /// Remove tiles that were last modified after expiry from the specified store + /// + /// Returns the number of tiles that were actually deleted (they were + /// orphaned). See [deleteTile] for more information about orphan tiles. + /// {@endtemplate} + Future removeTilesOlderThan({ + required String storeName, + required DateTime expiry, + }); + + /// {@template fmtc.backend.readMetadata} + /// Retrieve the stored metadata for the specified store + /// {@endtemplate} + Future> readMetadata({ + required String storeName, + }); + + /// {@template fmtc.backend.setMetadata} + /// Set a key-value pair in the metadata for the specified store + /// + /// > [!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. + /// {@endtemplate} + Future setMetadata({ + required String storeName, + required String key, + required String value, + }); + + /// {@template fmtc.backend.setBulkMetadata} + /// Set multiple key-value pairs in the metadata for the specified store + /// + /// Note that this operation will overwrite any existing value for each + /// specified key. + /// {@endtemplate} + Future setBulkMetadata({ + required String storeName, + required Map kvs, + }); + + /// {@template fmtc.backend.removeMetadata} + /// Remove the specified key from the metadata for the specified store + /// + /// Returns the value associated with key before it was removed, or `null` if + /// it was not present. + /// {@endtemplate} + Future removeMetadata({ + required String storeName, + required String key, + }); + + /// {@template fmtc.backend.resetMetadata} + /// Clear the metadata for the specified store + /// + /// This operation cannot be undone! Ensure you confirm with the user that + /// this action is expected. + /// {@endtemplate} + Future resetMetadata({ + required String storeName, + }); + + /// List all registered recovery regions + /// + /// Not all regions are failed, requires the [RootRecovery] object to + /// determine this. + Future> listRecoverableRegions(); + + /// Retrieve the specified registered recovery region + /// + /// Not all regions are failed, requires the [RootRecovery] object to + /// determine this. + Future getRecoverableRegion({ + required int id, + }); + + /// {@template fmtc.backend.cancelRecovery} + /// Safely cancel the specified recoverable region + /// {@endtemplate} + Future cancelRecovery({ + required int id, + }); + + /// {@template fmtc.backend.watchRecovery} + /// Watch for changes to the recovery system + /// + /// Useful to update UI only when required, for example, in a `StreamBuilder`. + /// Whenever this has an event, it is likely the other statistics will have + /// changed. + /// {@endtemplate} + Stream watchRecovery({ + required bool triggerImmediately, + }); + + /// {@template fmtc.backend.watchStores} + /// Watch for changes in the specified stores + /// + /// Useful to update UI only when required, for example, in a `StreamBuilder`. + /// Whenever this has an event, it is likely the other statistics will have + /// changed. + /// + /// Emits an event every time a change is made to a store: + /// * a statistic change, which should include every time a tile is changed + /// * a metadata change + /// {@endtemplate} + Stream watchStores({ + required List storeNames, + required bool triggerImmediately, + }); + + /// Create an archive at the file [path] containing the specifed stores and + /// their respective tiles + /// + /// See [RootExternal] for more information about expected behaviour and + /// errors. + Future exportStores({ + required String path, + required List storeNames, + }); + + /// Load the specified stores (or all stores if `null`) from the archive file + /// at [path] into the current root, using [strategy] where there are + /// conflicts + /// + /// See [RootExternal] for more information about expected behaviour and + /// errors. + /// + /// See [ImportResult] for information about how to handle the response. + ImportResult importStores({ + required String path, + required ImportConflictStrategy strategy, + required List? storeNames, + }); + + /// Check the stores available inside the archive file at [path] + /// + /// See [RootExternal] for more information about expected behaviour and + /// errors. + Future> listImportableStores({ + required String path, + }); +} diff --git a/lib/src/backend/interfaces/backend/internal_thread_safe.dart b/lib/src/backend/interfaces/backend/internal_thread_safe.dart new file mode 100644 index 00000000..e38d173a --- /dev/null +++ b/lib/src/backend/interfaces/backend/internal_thread_safe.dart @@ -0,0 +1,87 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'dart:async'; +import 'dart:typed_data'; + +import '../../../../flutter_map_tile_caching.dart'; +import '../../export_internal.dart'; + +/// An abstract interface that FMTC will use to communicate with a storage +/// 'backend' (usually one root), from an existing bulk downloading thread +/// +/// Should implement methods that operate in the same thread. Must be sendable +/// between isolates when uninitialised, because it will be operated in another +/// thread. Must be suitable for simultaneous [initialise]ation across multiple +/// threads. +/// +/// Should be set-up ready for intialisation, and set in the +/// [FMTCBackendAccessThreadSafe], from the initialisation of +/// [FMTCBackendInternal]. See documentation on that class for more information. +/// +/// Methods with a doc template in the doc string are for 'direct' public +/// invocation. +/// +/// See [FMTCBackend] for more information. +abstract interface class FMTCBackendInternalThreadSafe { + const FMTCBackendInternalThreadSafe._(); + + /// Generic description/name of this backend + abstract final String friendlyIdentifier; + + /// Start this thread safe database operator + FutureOr initialise(); + + /// Stop this thread safe database operator + FutureOr uninitialise(); + + /// Create another instance of this internal thread that relies on the same + /// root + /// + /// This method makes another uninitialised instance which must be safe to + /// send through isolates, unlike an initialised instance. + FMTCBackendInternalThreadSafe duplicate(); + + /// Retrieve a raw tile by the specified URL + /// + /// If [storeName] is specified, the tile will be limited to the specified + /// store - if it exists in another store, it will not be returned. + FutureOr readTile({ + required String url, + String? storeName, + }); + + /// Create or update a tile (given a [url] and its [bytes]) in the specified + /// store + /// + /// May share logic with [FMTCBackendInternal.writeTile]. + FutureOr writeTile({ + required String storeName, + required String url, + required Uint8List bytes, + }); + + /// Create or update multiple tiles (given given their respective [urls] and + /// [bytess]) in the specified store + FutureOr writeTiles({ + required String storeName, + required List urls, + required List bytess, + }); + + /// Create a recovery entity with a recoverable region from the specified + /// components + FutureOr startRecovery({ + required int id, + required String storeName, + required DownloadableRegion region, + required int endTile, + }); + + /// Update the specified recovery entity with the new [RecoveredRegion.start] + /// (equivalent) + FutureOr updateRecovery({ + required int id, + required int newStartTile, + }); +} diff --git a/lib/src/backend/interfaces/models.dart b/lib/src/backend/interfaces/models.dart new file mode 100644 index 00000000..cd764328 --- /dev/null +++ b/lib/src/backend/interfaces/models.dart @@ -0,0 +1,44 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// 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 + /// + /// This is passed through [obscureQueryParams] before storage here, and so + /// may not be the same as the network URL. + 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. + 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/bulk_tile_writer.dart b/lib/src/bulk_download/bulk_tile_writer.dart deleted file mode 100644 index 68df7135..00000000 --- a/lib/src/bulk_download/bulk_tile_writer.dart +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'dart:async'; -import 'dart:isolate'; - -import 'package:async/async.dart'; -import 'package:flutter/foundation.dart'; -import 'package:isar/isar.dart'; -import 'package:meta/meta.dart'; - -import '../../flutter_map_tile_caching.dart'; -import '../db/defs/metadata.dart'; -import '../db/defs/store_descriptor.dart'; -import '../db/defs/tile.dart'; -import '../db/tools.dart'; -import 'tile_progress.dart'; - -/// Handles tile writing during a bulk download -/// -/// Note that this is designed for performance, relying on isolate workers to -/// carry out expensive operations. -@internal -class BulkTileWriter { - BulkTileWriter._(); - - static BulkTileWriter? _instance; - static BulkTileWriter get instance => _instance!; - - late ReceivePort _recievePort; - late DownloadBufferMode _bufferMode; - late StreamController _downloadStream; - - late SendPort sendPort; - late StreamQueue events; - - static Future start({ - required FMTCTileProvider provider, - required DownloadBufferMode bufferMode, - required int? bufferLimit, - required String directory, - required StreamController streamController, - }) async { - final btw = BulkTileWriter._() - .._recievePort = ReceivePort() - .._bufferMode = bufferMode - .._downloadStream = streamController; - - await Isolate.spawn( - bufferMode == DownloadBufferMode.disabled - ? _instantWorker - : _bufferWorker, - btw._recievePort.sendPort, - ); - - btw - ..events = StreamQueue(btw._recievePort) - ..sendPort = await btw.events.next - ..sendPort.send( - bufferMode == DownloadBufferMode.disabled - ? [ - provider.storeDirectory.storeName, - directory, - ] - : [ - provider.storeDirectory.storeName, - directory, - bufferMode, - bufferLimit ?? - (bufferMode == DownloadBufferMode.tiles ? 500 : 2000000), - ], - ); - - _instance = btw; - } - - static Future stop(Uint8List? tileImage) async { - if (_instance == null) return; - - instance.sendPort.send(null); - if (instance._bufferMode != DownloadBufferMode.disabled) { - instance._downloadStream.add( - TileProgress( - failedUrl: null, - tileImage: tileImage, - wasSeaTile: false, - wasExistingTile: false, - wasCancelOperation: true, - bulkTileWriterResponse: await instance.events.next, - ), - ); - } - await instance.events.cancel(immediate: true); - - _instance = null; - } -} - -/// Isolate ('worker') for [BulkTileWriter] that supports buffered tile writing -/// -/// Starting this worker will send its recieve port to the specified [sendPort], -/// to be used for further communication, as described below: -/// -/// The first incoming message is expected to contain setup information, namely -/// the store name that will be targeted, the buffer mode, and the buffer limit, -/// as a list. No response is sent to this message. -/// -/// Following incoming messages are expected to either contain tile information -/// or to signal the end of the worker's lifespan. Should the message not be -/// null, a list will be expected where the first element is the tile URL and the -/// second element is the tile bytes. Should the message be null, the worker will -/// be terminated as described below. Responses are defined below. -/// -/// On reciept of a tile descriptor, it will be added to the buffer. If the total -/// buffer size then exceeds the limit defined in the setup message, the buffer -/// will be written to the database, then cleared. In this case, the response -/// will be a list of the total number of tiles and the total number of bytes -/// now written to the database. If the limit is not exceeded, the tile will not -/// be written, and the response will be null, indicating that there is no more -/// information, but the tile was processed correctly. -/// -/// On reciept of the `null` termination message, the buffer will be written, -/// regardless of it's length. This worker will then be killed, with a response -/// of the total number of tiles written, regardless of whether any tiles were -/// just written. -/// -/// It is illegal to kill this isolate externally, as this may lead to data loss. -/// Always terminate by sending the termination (`null`) message. -/// -/// It is illegal to send corrupted/invalid/unknown messages, as this will likely -/// crash the worker, leading to data loss. No validation is performed on -/// incoming data. -Future _bufferWorker(SendPort sendPort) async { - final rp = ReceivePort(); - sendPort.send(rp.sendPort); - final recievePort = rp.asBroadcastStream(); - - final setupInfo = await recievePort.first as List; - final db = Isar.openSync( - [DbStoreDescriptorSchema, DbTileSchema, DbMetadataSchema], - name: DatabaseTools.hash(setupInfo[0]).toString(), - directory: setupInfo[1], - inspector: false, - ); - - final bufferMode = setupInfo[2] as DownloadBufferMode; - final bufferLimit = setupInfo[3] as int; - - final tileBuffer = {}; - - int totalTilesWritten = 0; - int totalBytesWritten = 0; - - int currentTilesBuffered = 0; - int currentBytesBuffered = 0; - - void writeBuffer() { - db.writeTxnSync(() => tileBuffer.forEach(db.tiles.putSync)); - - totalBytesWritten += currentBytesBuffered; - totalTilesWritten += currentTilesBuffered; - - tileBuffer.clear(); - currentBytesBuffered = 0; - currentTilesBuffered = 0; - } - - recievePort.where((i) => i == null).listen((_) { - writeBuffer(); - Isolate.exit(sendPort, [totalTilesWritten, totalBytesWritten]); - }); - recievePort.where((i) => i != null).listen((info) { - currentBytesBuffered += Uint8List.fromList((info as List)[1]).lengthInBytes; - currentTilesBuffered++; - tileBuffer.add(DbTile(url: info[0], bytes: info[1])); - if ((bufferMode == DownloadBufferMode.tiles - ? currentTilesBuffered - : currentBytesBuffered) > - bufferLimit) { - writeBuffer(); - sendPort.send([totalTilesWritten, totalBytesWritten]); - } else { - sendPort.send(null); - } - }); -} - -/// Isolate ('worker') for [BulkTileWriter] that doesn't supports buffered tile -/// writing -/// -/// Starting this worker will send its recieve port to the specified [sendPort], -/// to be used for further communication, as described below: -/// -/// The first incoming message is expected to contain setup information, namely -/// the store name that will be targeted. No response is sent to this message. -/// -/// Following incoming messages are expected to either contain tile information -/// or to signal the end of the worker's lifespan. Should the message not be -/// null, a list will be expected where the first element is the tile URL and the -/// second element is the tile bytes. Should the message be null, the worker will -/// be terminated immediatley without a response. -/// -/// On reciept of a tile descriptor, the tile will be written to the database, -/// and the response will be `null`. -/// -/// It is not recommended to kill this isolate externally. Prefer termination by -/// sending the termination (`null`) message. -/// -/// It is illegal to send corrupted/invalid/unknown messages, as this will likely -/// crash the worker, leading to data loss. No validation is performed on -/// incoming data. -Future _instantWorker(SendPort sendPort) async { - final rp = ReceivePort(); - sendPort.send(rp.sendPort); - final recievePort = rp.asBroadcastStream(); - - final setupInfo = await recievePort.first as List; - final db = Isar.openSync( - [DbStoreDescriptorSchema, DbTileSchema, DbMetadataSchema], - name: DatabaseTools.hash(setupInfo[0]).toString(), - directory: setupInfo[1], - inspector: false, - ); - - recievePort.where((i) => i == null).listen((_) => Isolate.exit()); - recievePort.where((i) => i != null).listen((info) { - db.writeTxnSync( - () => db.tiles.putSync(DbTile(url: (info as List)[0], bytes: info[1])), - ); - sendPort.send(null); - }); -} diff --git a/lib/src/bulk_download/download_progress.dart b/lib/src/bulk_download/download_progress.dart index 4c6eed24..0321af29 100644 --- a/lib/src/bulk_download/download_progress.dart +++ b/lib/src/bulk_download/download_progress.dart @@ -3,228 +3,293 @@ part of '../../flutter_map_tile_caching.dart'; -/// Represents the progress of an ongoing or finished (if [percentageProgress] -/// is 100%) bulk download +/// Statistics and information about the current progress of the download /// -/// Should avoid manual construction, use named constructor -/// [DownloadProgress.empty] to generate placeholders. +/// See the documentation on each individual property for more information. +@immutable class DownloadProgress { - /// Identification number of the corresponding download - /// - /// A zero identification denotes that there is no corresponding download yet, - /// usually due to the initialisation with [DownloadProgress.empty]. - final int downloadID; + 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; - /// Class for managing the average tiles per second - /// ([InternalProgressTimingManagement.averageTPS]) measurement of a download - final InternalProgressTimingManagement _progressManagement; + 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, + ); - /// Number of tiles downloaded successfully + /// The result of the latest attempted tile /// - /// If using buffering, it is important to note that these tiles will be - /// written successfully (becoming part of [persistedTiles]), except in the - /// event of a crash. In this case, the tiles that fall under the difference - /// of [persistedTiles] - [successfulTiles] will be lost. - /// - /// For more information about buffering, see the - /// [appropriate docs page](https://fmtc.jaffaketchup.dev/bulk-downloading/foreground/buffering). - final int successfulTiles; + /// {@macro fmtc.tileevent.extraConsiderations} + TileEvent get latestTileEvent => _latestTileEvent!; + final TileEvent? _latestTileEvent; - /// Number of tiles persisted successfully (only significant when using - /// buffering) - /// - /// This value will not necessarily change with every change to - /// [successfulTiles]. It will only change after the buffer has been written. + /// The number of new tiles successfully downloaded and in the tile buffer or + /// cached /// - /// Tiles that fall within this number are safely persisted to the database and - /// cannot be lost as a consequence of a crash, unlike [successfulTiles]. + /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. /// - /// For more information about buffering, see the - /// [appropriate docs page](https://fmtc.jaffaketchup.dev/bulk-downloading/foreground/buffering). - final int persistedTiles; - - /// List of URLs of failed tiles - final List failedTiles; + /// Includes [bufferedTiles]. + final int cachedTiles; - /// Approximate total number of tiles to be downloaded - final int maxTiles; + /// 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; - /// Number of kibibytes successfully downloaded + /// The number of new tiles successfully downloaded and in the tile buffer + /// waiting to be cached /// - /// If using buffering, it is important to note that these tiles will be - /// written successfully (becoming part of [persistedSize]), except in the - /// event of a crash. In this case, the tiles that fall under the difference - /// of [persistedSize] - [successfulSize] will be lost. + /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. /// - /// For more information about buffering, see the - /// [appropriate docs page](https://fmtc.jaffaketchup.dev/bulk-downloading/foreground/buffering). - final double successfulSize; + /// Part of [cachedTiles]. + final int bufferedTiles; - /// Number of kibibytes successfully persisted (only significant when using - /// buffering) + /// The total size (in KiB) of new tiles successfully downloaded and in the + /// tile buffer waiting to be cached /// - /// This value will not necessarily change with every change to - /// [successfulSize]. It will only change after the buffer has been written. + /// [TileEvent]s with the result category of [TileEventResultCategory.cached]. /// - /// Tiles that fall within this number are safely persisted to the database and - /// cannot be lost as a consequence of a crash, unlike [successfulSize]. + /// 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` /// - /// For more information about buffering, see the - /// [appropriate docs page](https://fmtc.jaffaketchup.dev/bulk-downloading/foreground/buffering). - final double persistedSize; + /// [TileEvent]s with the result category of [TileEventResultCategory.skipped]. + final int skippedTiles; - /// Number of tiles removed because they were entirely sea (these also make up - /// part of [successfulTiles]) + /// 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` /// - /// Only applicable if sea tile removal is enabled, otherwise this value is - /// always 0. - final int seaTiles; + /// [TileEvent]s with the result category of [TileEventResultCategory.skipped]. + final double skippedSize; - /// Number of tiles not downloaded because they already existed (these also - /// make up part of [successfulTiles]) + /// The number of tiles that were not successfully downloaded, potentially for + /// a variety of reasons /// - /// Only applicable if redownload prevention is enabled, otherwise this value - /// is always 0. - final int existingTiles; + /// [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; - /// Elapsed duration since start of download process - final Duration duration; + /// 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; - /// Get the [ImageProvider] of the last tile that was downloaded + /// The current elapsed duration of the download /// - /// Is `null` if the last tile failed, or the tile already existed and - /// `preventRedownload` is enabled. - final MemoryImage? tileImage; + /// Will be accurate to within `maxReportInterval` or better. + final Duration elapsedDuration; - /// The [DownloadBufferMode] in use + /// The approximate/estimated number of attempted tiles per second (TPS) /// - /// If [DownloadBufferMode.disabled], then [persistedSize] and [persistedTiles] - /// will remain 0. - final DownloadBufferMode bufferMode; + /// 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; - /// Number of attempted tile downloads, including failure + /// Whether the number of [tilesPerSecond] could be higher, but is currently + /// capped by the set `rateLimit` /// - /// Is equal to `successfulTiles + failedTiles.length`. - int get attemptedTiles => successfulTiles + failedTiles.length; + /// This is only an approximate indicator. + final bool isTPSArtificiallyCapped; - /// Approximate number of tiles remaining to be downloaded + /// Whether the download is now complete /// - /// Is equal to `approxMaxTiles - attemptedTiles`. - int get remainingTiles => maxTiles - attemptedTiles; + /// 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; - /// Percentage of tiles saved by using sea tile removal (ie. discount) + /// The number of tiles that were either cached, in buffer, or skipped /// - /// Only applicable if sea tile removal is enabled, otherwise this value is - /// always 0. + /// Equal to [cachedTiles] + [skippedTiles]. + int get successfulTiles => cachedTiles + skippedTiles; + + /// The total size (in KiB) of tiles that were either cached, in buffer, or + /// skipped /// - /// Is equal to - /// `100 - ((((successfulTiles - existingTiles) - seaTiles) / successfulTiles) * 100)`. - double get seaTilesDiscount => seaTiles == 0 - ? 0 - : 100 - - ((((successfulTiles - existingTiles) - seaTiles) / successfulTiles) * - 100); + /// Equal to [cachedSize] + [skippedSize]. + double get successfulSize => cachedSize + skippedSize; - /// Percentage of tiles saved by using redownload prevention (ie. discount) + /// The number of tiles that have been attempted, with any result /// - /// Only applicable if redownload prevention is enabled, otherwise this value - /// is always 0. + /// Equal to [successfulTiles] + [failedTiles]. + int get attemptedTiles => successfulTiles + failedTiles; + + /// The number of tiles that have not yet been attempted /// - /// Is equal to - /// `100 - ((((successfulTiles - seaTiles) - existingTiles) / successfulTiles) * 100)`. - double get existingTilesDiscount => existingTiles == 0 - ? 0 - : 100 - - ((((successfulTiles - seaTiles) - existingTiles) / successfulTiles) * - 100); + /// Equal to [maxTiles] - [attemptedTiles]. + int get remainingTiles => maxTiles - attemptedTiles; - /// Approximate percentage of process complete + /// The number of attempted tiles over the number of available tiles as a + /// percentage /// - /// Is equal to `(attemptedTiles / maxTiles) * 100`. + /// Equal to [attemptedTiles] / [maxTiles] multiplied by 100. double get percentageProgress => (attemptedTiles / maxTiles) * 100; - /// Retrieve the average number of tiles per second that are being downloaded + /// The estimated total duration of the download /// - /// Uses an exponentially smoothed moving average algorithm instead of a linear - /// average algorithm. This should lead to more accurate estimations based on - /// this data. The full original algorithm (written in Python) can be found at - /// https://stackoverflow.com/a/54264570/11846040. - double get averageTPS => _progressManagement.averageTPS; - - /// Estimate duration for entire download process, using [averageTPS] + /// It may or may not be accurate, except when [isComplete] is `true`, in which + /// event, this will always equal [elapsedDuration]. /// - /// Uses an exponentially smoothed moving average algorithm instead of a linear - /// average algorithm. This should lead to more accurate duration calculations, - /// but may not return the same result as expected. The full original algorithm - /// (written in Python) can be found at - /// https://stackoverflow.com/a/54264570/11846040. - Duration get estTotalDuration => Duration( - seconds: (maxTiles / averageTPS.clamp(1, double.infinity)).round(), - ); + /// 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), + ); - /// Estimate remaining duration until the end of the download process + /// The estimated remaining duration of the download. /// - /// Uses an exponentially smoothed moving average algorithm instead of a linear - /// average algorithm. This should lead to more accurate duration calculations, - /// but may not return the same result as expected. The full original algorithm - /// (written in Python) can be found at - /// https://stackoverflow.com/a/54264570/11846040. - Duration get estRemainingDuration => estTotalDuration - duration; + /// 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; - /// Avoid construction using this method. Use [DownloadProgress.empty] to generate empty placeholders where necessary. - DownloadProgress._({ - required this.downloadID, - required this.successfulTiles, - required this.persistedTiles, - required this.failedTiles, - required this.maxTiles, - required this.successfulSize, - required this.persistedSize, - required this.seaTiles, - required this.existingTiles, - required this.duration, - required this.tileImage, - required this.bufferMode, - required InternalProgressTimingManagement progressManagement, - }) : _progressManagement = progressManagement; - - /// Create an empty placeholder (all values set to 0 or empty) [DownloadProgress], useful for `initialData` in a [StreamBuilder] - DownloadProgress.empty() - : downloadID = 0, - successfulTiles = 0, - persistedTiles = 0, - failedTiles = [], - successfulSize = 0, - persistedSize = 0, - maxTiles = 1, - seaTiles = 0, - existingTiles = 0, - duration = Duration.zero, - tileImage = null, - bufferMode = DownloadBufferMode.disabled, - _progressManagement = InternalProgressTimingManagement(); - - //! GENERAL OBJECT STUFF !// + 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) { - if (identical(this, other)) return true; - - return other is DownloadProgress && - other.successfulTiles == successfulTiles && - other.failedTiles == failedTiles && - other.maxTiles == maxTiles && - other.seaTiles == seaTiles && - other.existingTiles == existingTiles && - other.duration == duration; - } + 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 => - successfulTiles.hashCode ^ - failedTiles.hashCode ^ - maxTiles.hashCode ^ - seaTiles.hashCode ^ - existingTiles.hashCode ^ - duration.hashCode; + 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/downloader.dart b/lib/src/bulk_download/downloader.dart deleted file mode 100644 index f5598f25..00000000 --- a/lib/src/bulk_download/downloader.dart +++ /dev/null @@ -1,203 +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:isolate'; -import 'dart:typed_data'; - -import 'package:async/async.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:http/http.dart'; -import 'package:meta/meta.dart'; - -import '../../flutter_map_tile_caching.dart'; -import '../db/defs/tile.dart'; -import '../db/registry.dart'; -import '../db/tools.dart'; -import 'bulk_tile_writer.dart'; -import 'internal_timing_progress_management.dart'; -import 'tile_loops/shared.dart'; -import 'tile_progress.dart'; - -@internal -Future> bulkDownloader({ - required StreamController streamController, - required Completer cancelRequestSignal, - required Completer cancelCompleteSignal, - required DownloadableRegion region, - required FMTCTileProvider provider, - required Uint8List? seaTileBytes, - required InternalProgressTimingManagement progressManagement, - required BaseClient client, -}) async { - final tiles = FMTCRegistry.instance(provider.storeDirectory.storeName); - - final recievePort = ReceivePort(); - final tileIsolate = await Isolate.spawn( - region.type == RegionType.rectangle - ? TilesGenerator.rectangleTiles - : region.type == RegionType.circle - ? TilesGenerator.circleTiles - : TilesGenerator.lineTiles, - {'sendPort': recievePort.sendPort, 'region': region}, - onExit: recievePort.sendPort, - ); - final tileQueue = StreamQueue( - recievePort.skip(region.start).take( - region.end == null - ? 9223372036854775807 - : (region.end! - region.start), - ), - ); - final requestTilePort = (await tileQueue.next) as SendPort; - - final threadCompleters = List.generate( - region.parallelThreads + 1, - (_) => Completer(), - growable: false, - ); - - for (final threadCompleter in threadCompleters) { - unawaited(() async { - while (true) { - requestTilePort.send(null); - - final List? value; - try { - value = await tileQueue.next; - // ignore: avoid_catching_errors - } on StateError { - threadCompleter.complete(); - break; - } - - if (cancelRequestSignal.isCompleted) { - await tileQueue.cancel(immediate: true); - - tileIsolate.kill(priority: Isolate.immediate); - recievePort.close(); - - await BulkTileWriter.stop(null); - unawaited(streamController.close()); - - cancelCompleteSignal.complete(); - break; - } - - if (value == null) { - await tileQueue.cancel(); - - threadCompleter.complete(); - await Future.wait(threadCompleters.map((e) => e.future)); - - recievePort.close(); - - await BulkTileWriter.stop(null); - unawaited(streamController.close()); - - break; - } - - final coord = TileCoordinates( - value[0].toInt(), - value[1].toInt(), - value[2].toInt(), - ); - - final url = provider.getTileUrl(coord, region.options); - final existingTile = await tiles.tiles.get(DatabaseTools.hash(url)); - - try { - final List bytes = []; - - if (region.preventRedownload && existingTile != null) { - streamController.add( - TileProgress( - failedUrl: null, - tileImage: null, - wasSeaTile: false, - wasExistingTile: true, - wasCancelOperation: false, - bulkTileWriterResponse: null, - ), - ); - } - - final uri = Uri.parse(url); - final response = await client - .send(Request('GET', uri)..headers.addAll(provider.headers)); - - if (response.statusCode != 200) { - throw HttpException( - '[${response.statusCode}] ${response.reasonPhrase ?? 'Unknown Reason'}', - uri: uri, - ); - } - - int received = 0; - await for (final List evt in response.stream) { - bytes.addAll(evt); - received += evt.length; - progressManagement.registerEvent( - url, - TimestampProgress( - DateTime.now(), - received / (response.contentLength ?? 0), - ), - ); - } - - if (existingTile != null && - seaTileBytes != null && - const ListEquality().equals(bytes, seaTileBytes)) { - streamController.add( - TileProgress( - failedUrl: null, - tileImage: Uint8List.fromList(bytes), - wasSeaTile: true, - wasExistingTile: false, - wasCancelOperation: false, - bulkTileWriterResponse: null, - ), - ); - } - - BulkTileWriter.instance.sendPort.send( - List.unmodifiable( - [provider.settings.obscureQueryParams(url), bytes], - ), - ); - - streamController.add( - TileProgress( - failedUrl: null, - tileImage: Uint8List.fromList(bytes), - wasSeaTile: false, - wasExistingTile: false, - wasCancelOperation: false, - bulkTileWriterResponse: await BulkTileWriter.instance.events.next, - ), - ); - } catch (e) { - region.errorHandler?.call(e); - if (!streamController.isClosed) { - streamController.add( - TileProgress( - failedUrl: url, - tileImage: null, - wasSeaTile: false, - wasExistingTile: false, - wasCancelOperation: false, - bulkTileWriterResponse: null, - ), - ); - } - } - } - }()); - } - - return streamController.stream; -} diff --git a/lib/src/bulk_download/instance.dart b/lib/src/bulk_download/instance.dart new file mode 100644 index 00000000..ea8111af --- /dev/null +++ b/lib/src/bulk_download/instance.dart @@ -0,0 +1,30 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'package:meta/meta.dart'; + +@internal +class DownloadInstance { + DownloadInstance._(this.id); + static final _instances = {}; + + static DownloadInstance? registerIfAvailable(Object id) => + _instances[id] != null ? null : _instances[id] = DownloadInstance._(id); + static bool unregister(Object id) => _instances.remove(id) != null; + static DownloadInstance? get(Object id) => _instances[id]; + + final Object id; + + Future Function()? requestCancel; + + bool isPaused = false; + 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_timing_progress_management.dart b/lib/src/bulk_download/internal_timing_progress_management.dart deleted file mode 100644 index 47b44403..00000000 --- a/lib/src/bulk_download/internal_timing_progress_management.dart +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'dart:async'; - -import 'package:collection/collection.dart'; - -/// Object containing the [timestamp] of the measurement and the percentage -/// [progress] (0-1) of the applicable tile -class TimestampProgress { - /// Time at which the measurement of progress was taken - final DateTime timestamp; - - /// Percentage progress (0-1) of the applicable tile - final double progress; - - /// Object containing the [timestamp] of the measurement and the percentage - /// [progress] (0-1) of the applicable tile - TimestampProgress( - this.timestamp, - this.progress, - ); - - @override - String toString() => - 'TileTimestampProgress(timestamp: $timestamp, progress: $progress)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is TimestampProgress && - other.timestamp == timestamp && - other.progress == progress; - } - - @override - int get hashCode => timestamp.hashCode ^ progress.hashCode; -} - -/// Internal class for managing the tiles per second ([averageTPS]) measurement -/// of a download -class InternalProgressTimingManagement { - static const double _smoothing = 0.05; - - late Timer _timer; - final List _tpsMeasurements = []; - final Map _rawProgresses = {}; - double _averageTPS = 0; - - /// Retrieve the number of tiles per second - /// - /// Always 0 if [start] has not been called or [stop] has been called - double get averageTPS => _averageTPS; - - /// Calculate the number of tiles that are being downloaded per second on - /// average, and write to [averageTPS] - /// - /// Uses an exponentially smoothed moving average algorithm instead of a linear - /// average algorithm. This should lead to more accurate estimations based on - /// this data. The full original algorithm (written in Python) can be found at - /// https://stackoverflow.com/a/54264570/11846040. - void start() => _timer = Timer.periodic(const Duration(seconds: 1), (_) { - _rawProgresses.removeWhere( - (_, v) => v.timestamp - .isBefore(DateTime.now().subtract(const Duration(seconds: 1))), - ); - _tpsMeasurements.add(_rawProgresses.values.map((e) => e.progress).sum); - - _averageTPS = _tpsMeasurements.length == 1 - ? _tpsMeasurements[0] - : (_smoothing * _tpsMeasurements.last) + - ((1 - _smoothing) * - _tpsMeasurements.sum / - _tpsMeasurements.length); - }); - - /// Stop calculating the [averageTPS] measurement - void stop() { - _timer.cancel(); - _averageTPS = 0; - } - - /// Insert a new tile progress event into [_rawProgresses], to be accounted for - /// by [averageTPS] - void registerEvent(String url, TimestampProgress progress) => - _rawProgresses[url] = progress; -} diff --git a/lib/src/bulk_download/manager.dart b/lib/src/bulk_download/manager.dart new file mode 100644 index 00000000..867aa177 --- /dev/null +++ b/lib/src/bulk_download/manager.dart @@ -0,0 +1,357 @@ +// 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/rate_limited_stream.dart b/lib/src/bulk_download/rate_limited_stream.dart new file mode 100644 index 00000000..02665f19 --- /dev/null +++ b/lib/src/bulk_download/rate_limited_stream.dart @@ -0,0 +1,55 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +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 + /// + /// 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: + /// ``` + /// Input: .ABC....DE..F........GH + /// Output: .A..B..C..D..E..F....G..H + /// ``` + Stream rateLimit({ + required Duration minimumSpacing, + bool cancelOnError = false, + }) { + Completer emitEvt = Completer()..complete(); + final timer = Timer.periodic( + minimumSpacing, + (_) { + /// Trigger an event emission every period + if (!emitEvt.isCompleted) emitEvt.complete(); + }, + ); + + return transform>( + StreamTransformer.fromHandlers( + handleData: (data, sink) => sink.add( + (() async { + await emitEvt.future; // Await for the next signal from [timer] + emitEvt = Completer(); // Get [timer] ready for the next signal + return data; + })(), + ), + handleError: (error, stackTrace, sink) { + sink.addError(error, stackTrace); + if (cancelOnError) { + timer.cancel(); + sink.close(); + } + }, + handleDone: (sink) { + timer.cancel(); + sink.close(); + }, + ), + ).asyncMap((e) => e); + } +} diff --git a/lib/src/bulk_download/thread.dart b/lib/src/bulk_download/thread.dart new file mode 100644 index 00000000..0454eba9 --- /dev/null +++ b/lib/src/bulk_download/thread.dart @@ -0,0 +1,172 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +part of '../../flutter_map_tile_caching.dart'; + +Future _singleDownloadThread( + ({ + SendPort sendPort, + String storeName, + TileLayer options, + int maxBufferLength, + bool skipExistingTiles, + Uint8List? seaTileBytes, + Iterable obscuredQueryParams, + Map headers, + FMTCBackendInternalThreadSafe backend, + }) input, +) async { + // Setup two-way communications + final receivePort = ReceivePort(); + void send(Object m) => input.sendPort.send(m); + send(receivePort.sendPort); + + // Setup tile queue + final tileQueue = StreamQueue(receivePort); + + // Initialise a long lasting HTTP client + final httpClient = http.Client(); + + // Initialise the tile buffer arrays + final tileUrlsBuffer = []; + final tileBytesBuffer = []; + + await input.backend.initialise(); + + while (true) { + // Request new tile coords + send(0); + final rawCoords = (await tileQueue.next) as (int, int, int)?; + + // Cleanup resources and shutdown if no more coords available + if (rawCoords == null) { + receivePort.close(); + await tileQueue.cancel(immediate: true); + + httpClient.close(); + + if (tileUrlsBuffer.isNotEmpty) { + await input.backend.writeTiles( + storeName: input.storeName, + urls: tileUrlsBuffer, + bytess: tileBytesBuffer, + ); + } + + await input.backend.uninitialise(); + + Isolate.exit(); + } + + // Generate `TileCoordinates` + final coordinates = + TileCoordinates(rawCoords.$1, rawCoords.$2, rawCoords.$3); + + // 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, + ); + + // 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; + } + + // Fetch new tile from URL + final http.Response response; + try { + response = + await httpClient.get(Uri.parse(networkUrl), headers: input.headers); + } catch (e) { + send( + TileEvent._( + e is SocketException + ? TileEventResult.noConnectionDuringFetch + : TileEventResult.unknownFetchException, + url: networkUrl, + coordinates: coordinates, + fetchError: e, + ), + ); + continue; + } + + if (response.statusCode != 200) { + send( + TileEvent._( + TileEventResult.negativeFetchResponse, + url: networkUrl, + coordinates: coordinates, + fetchResponse: response, + ), + ); + continue; + } + + // Skip if tile is a sea tile & user demands sea tile pruning + if (const ListEquality().equals(response.bodyBytes, input.seaTileBytes)) { + send( + TileEvent._( + TileEventResult.isSeaTile, + url: networkUrl, + coordinates: coordinates, + tileImage: response.bodyBytes, + fetchResponse: response, + ), + ); + continue; + } + + // Write tile directly to database or place in buffer queue + if (input.maxBufferLength == 0) { + await input.backend.writeTile( + storeName: input.storeName, + url: matcherUrl, + bytes: response.bodyBytes, + ); + } else { + tileUrlsBuffer.add(matcherUrl); + tileBytesBuffer.add(response.bodyBytes); + } + + // Write buffer to database if necessary + final wasBufferReset = tileUrlsBuffer.length >= input.maxBufferLength; + if (wasBufferReset) { + await input.backend.writeTiles( + storeName: input.storeName, + urls: tileUrlsBuffer, + bytess: tileBytesBuffer, + ); + tileUrlsBuffer.clear(); + tileBytesBuffer.clear(); + } + + // Return successful response to user + send( + TileEvent._( + TileEventResult.success, + url: networkUrl, + coordinates: coordinates, + tileImage: response.bodyBytes, + fetchResponse: response, + wasBufferReset: wasBufferReset, + ), + ); + } +} diff --git a/lib/src/bulk_download/tile_event.dart b/lib/src/bulk_download/tile_event.dart new file mode 100644 index 00000000..07660bbd --- /dev/null +++ b/lib/src/bulk_download/tile_event.dart @@ -0,0 +1,179 @@ +// 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/bulk_download/tile_loops/count.dart b/lib/src/bulk_download/tile_loops/count.dart index f9cd825e..f4c5ae39 100644 --- a/lib/src/bulk_download/tile_loops/count.dart +++ b/lib/src/bulk_download/tile_loops/count.dart @@ -3,118 +3,139 @@ part of 'shared.dart'; -class TilesCounter { +/// 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]. +/// +/// Where possible, these methods do not generate every coordinate for improved +/// efficiency, as the number of tiles can be counted without looping through +/// them all (in most cases). See [TileGenerators] for methods that actually +/// generate the coordinates of each tile, but with added complexity. +/// +/// The number of tiles returned by each method must match the number of tiles +/// returned by the respective method in [TileGenerators]. This is enforced by +/// automated tests. +@internal +class TileCounters { + /// Trim [numOfTiles] 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); + + /// Returns the number of tiles within a [DownloadableRegion] with generic type + /// [RectangleRegion] + @internal static int rectangleTiles(DownloadableRegion region) { - final tileSize = _getTileSize(region); - final bounds = (region.originalRegion as RectangleRegion).bounds; + region as DownloadableRegion; - int numberOfTiles = 0; - for (int zoomLvl = region.minZoom; zoomLvl <= region.maxZoom; zoomLvl++) { - final CustomPoint nwCustomPoint = region.crs - .latLngToPoint(bounds.northWest, zoomLvl.toDouble()) - .unscaleBy(tileSize) + final northWest = region.originalRegion.bounds.northWest; + final southEast = region.originalRegion.bounds.southEast; + + var numberOfTiles = 0; + + for (double zoomLvl = region.minZoom.toDouble(); + zoomLvl <= region.maxZoom; + zoomLvl++) { + final nwPoint = (region.crs.latLngToPoint(northWest, zoomLvl) / + region.options.tileSize) .floor(); - final CustomPoint seCustomPoint = region.crs - .latLngToPoint(bounds.southEast, zoomLvl.toDouble()) - .unscaleBy(tileSize) + final sePoint = (region.crs.latLngToPoint(southEast, zoomLvl) / + region.options.tileSize) .ceil() - - const CustomPoint(1, 1); + const Point(1, 1); - numberOfTiles += (seCustomPoint.x - nwCustomPoint.x + 1).toInt() * - (seCustomPoint.y - nwCustomPoint.y + 1).toInt(); + numberOfTiles += + (sePoint.x - nwPoint.x + 1) * (sePoint.y - nwPoint.y + 1); } - return numberOfTiles; + + return _trimToRange(region, numberOfTiles); } + /// Returns the number of tiles within a [DownloadableRegion] with generic type + /// [CircleRegion] + @internal static int circleTiles(DownloadableRegion region) { - // 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 - - final tileSize = _getTileSize(region); + region as DownloadableRegion; + final circleOutline = region.originalRegion.toOutline(); // Format: Map>> - final Map>> outlineTileNums = {}; + final outlineTileNums = >>{}; int numberOfTiles = 0; for (int zoomLvl = region.minZoom; zoomLvl <= region.maxZoom; zoomLvl++) { - outlineTileNums[zoomLvl] = >{}; + outlineTileNums[zoomLvl] = {}; - for (final LatLng node in circleOutline) { - final CustomPoint tile = region.crs - .latLngToPoint(node, zoomLvl.toDouble()) - .unscaleBy(tileSize) + for (final node in circleOutline) { + final tile = (region.crs.latLngToPoint(node, zoomLvl.toDouble()) / + region.options.tileSize) .floor(); - outlineTileNums[zoomLvl]![tile.x.toInt()] ??= [ - 9223372036854775807, - -9223372036854775808, - ]; - - outlineTileNums[zoomLvl]![tile.x.toInt()] = [ - tile.y < outlineTileNums[zoomLvl]![tile.x.toInt()]![0] - ? tile.y.toInt() - : outlineTileNums[zoomLvl]![tile.x.toInt()]![0], - tile.y > outlineTileNums[zoomLvl]![tile.x.toInt()]![1] - ? tile.y.toInt() - : outlineTileNums[zoomLvl]![tile.x.toInt()]![1], + 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], ]; } - for (final int x in outlineTileNums[zoomLvl]!.keys) { + for (final x in outlineTileNums[zoomLvl]!.keys) { numberOfTiles += outlineTileNums[zoomLvl]![x]![1] - outlineTileNums[zoomLvl]![x]![0] + 1; } } - return numberOfTiles; + return _trimToRange(region, numberOfTiles); } + /// Returns the number of tiles within a [DownloadableRegion] with generic type + /// [LineRegion] + @internal static int lineTiles(DownloadableRegion region) { - // 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 + region as DownloadableRegion; + + // Overlap algorithm originally in Python, available at + // https://stackoverflow.com/a/56962827/11846040 bool overlap(_Polygon a, _Polygon b) { for (int x = 0; x < 2; x++) { final _Polygon polygon = x == 0 ? a : b; for (int i1 = 0; i1 < polygon.points.length; i1++) { - final int i2 = (i1 + 1) % polygon.points.length; - final CustomPoint p1 = polygon.points[i1]; - final CustomPoint p2 = polygon.points[i2]; - - final CustomPoint normal = - CustomPoint(p2.y - p1.y, p1.x - p2.x); - - double minA = double.infinity; - double maxA = double.negativeInfinity; - - for (final CustomPoint p in a.points) { - final num projected = normal.x * p.x + normal.y * p.y; - - if (projected < minA) minA = projected.toDouble(); - if (projected > maxA) maxA = projected.toDouble(); + final i2 = (i1 + 1) % polygon.points.length; + final p1 = polygon.points[i1]; + final p2 = polygon.points[i2]; + + final normal = Point(p2.y - p1.y, p1.x - p2.x); + + var minA = largestInt; + var maxA = smallestInt; + for (final p in a.points) { + final projected = normal.x * p.x + normal.y * p.y; + if (projected < minA) minA = projected; + if (projected > maxA) maxA = projected; } - double minB = double.infinity; - double maxB = double.negativeInfinity; - - for (final CustomPoint p in b.points) { - final num projected = normal.x * p.x + normal.y * p.y; - - if (projected < minB) minB = projected.toDouble(); - if (projected > maxB) maxB = projected.toDouble(); + var minB = largestInt; + var maxB = smallestInt; + for (final p in b.points) { + final projected = normal.x * p.x + normal.y * p.y; + if (projected < minB) minB = projected; + if (projected > maxB) maxB = projected; } if (maxA < minB || maxB < minA) return false; @@ -124,85 +145,94 @@ class TilesCounter { return true; } - final tileSize = _getTileSize(region); - final lineOutline = (region.originalRegion as LineRegion).toOutlines(1); + final lineOutline = region.originalRegion.toOutlines(1); int numberOfTiles = 0; - for (int zoomLvl = region.minZoom; zoomLvl <= region.maxZoom; zoomLvl++) { + for (double zoomLvl = region.minZoom.toDouble(); + zoomLvl <= region.maxZoom; + zoomLvl++) { + final generatedTiles = []; + for (final rect in lineOutline) { - final LatLng rrBottomLeft = rect[0]; - final LatLng rrBottomRight = rect[1]; - final LatLng rrTopRight = rect[2]; - final LatLng rrTopLeft = rect[3]; - - final List rrAllLat = [ - rrTopLeft.latitude, - rrTopRight.latitude, - rrBottomLeft.latitude, - rrBottomRight.latitude, + final rotatedRectangle = ( + bottomLeft: rect[0], + bottomRight: rect[1], + topRight: rect[2], + topLeft: rect[3], + ); + + final rotatedRectangleLats = [ + rotatedRectangle.topLeft.latitude, + rotatedRectangle.topRight.latitude, + rotatedRectangle.bottomLeft.latitude, + rotatedRectangle.bottomRight.latitude, ]; - final List rrAllLon = [ - rrTopLeft.longitude, - rrTopRight.longitude, - rrBottomLeft.longitude, - rrBottomRight.longitude, + final rotatedRectangleLngs = [ + rotatedRectangle.topLeft.longitude, + rotatedRectangle.topRight.longitude, + rotatedRectangle.bottomLeft.longitude, + rotatedRectangle.bottomRight.longitude, ]; - final CustomPoint rrNorthWest = region.crs - .latLngToPoint(rrTopLeft, zoomLvl.toDouble()) - .unscaleBy(tileSize) - .floor(); - final CustomPoint rrNorthEast = region.crs - .latLngToPoint(rrTopRight, zoomLvl.toDouble()) - .unscaleBy(tileSize) - .ceil() - - const CustomPoint(1, 0); - final CustomPoint rrSouthWest = region.crs - .latLngToPoint(rrBottomLeft, zoomLvl.toDouble()) - .unscaleBy(tileSize) - .ceil() - - const CustomPoint(0, 1); - final CustomPoint rrSouthEast = region.crs - .latLngToPoint(rrBottomRight, zoomLvl.toDouble()) - .unscaleBy(tileSize) - .ceil() - - const CustomPoint(1, 1); - - final CustomPoint srNorthWest = region.crs - .latLngToPoint( - LatLng(rrAllLat.max, rrAllLon.min), - zoomLvl.toDouble(), - ) - .unscaleBy(tileSize) + final rotatedRectangleNW = + (region.crs.latLngToPoint(rotatedRectangle.topLeft, zoomLvl) / + region.options.tileSize) + .floor(); + final rotatedRectangleNE = + (region.crs.latLngToPoint(rotatedRectangle.topRight, zoomLvl) / + region.options.tileSize) + .ceil() - + const Point(1, 0); + final rotatedRectangleSW = + (region.crs.latLngToPoint(rotatedRectangle.bottomLeft, zoomLvl) / + region.options.tileSize) + .ceil() - + const Point(0, 1); + final rotatedRectangleSE = + (region.crs.latLngToPoint(rotatedRectangle.bottomRight, zoomLvl) / + region.options.tileSize) + .ceil() - + const Point(1, 1); + + final straightRectangleNW = (region.crs.latLngToPoint( + LatLng(rotatedRectangleLats.max, rotatedRectangleLngs.min), + zoomLvl, + ) / + region.options.tileSize) .floor(); - final CustomPoint srSouthEast = region.crs - .latLngToPoint( - LatLng(rrAllLat.min, rrAllLon.max), - zoomLvl.toDouble(), - ) - .unscaleBy(tileSize) + final straightRectangleSE = (region.crs.latLngToPoint( + LatLng( + rotatedRectangleLats.min, + rotatedRectangleLngs.max, + ), + zoomLvl, + ) / + region.options.tileSize) .ceil() - - const CustomPoint(1, 1); + const Point(1, 1); - for (num x = srNorthWest.x; x <= srSouthEast.x; x++) { + for (int x = straightRectangleNW.x; x <= straightRectangleSE.x; x++) { bool foundOverlappingTile = false; - for (num y = srNorthWest.y; y <= srSouthEast.y; y++) { + for (int y = straightRectangleNW.y; y <= straightRectangleSE.y; y++) { + final tile = _Polygon( + Point(x, y), + Point(x + 1, y), + Point(x + 1, y + 1), + Point(x, y + 1), + ); + if (generatedTiles.contains(tile.hashCode)) continue; if (overlap( _Polygon( - rrNorthWest, - rrNorthEast, - rrSouthEast, - rrSouthWest, - ), - _Polygon( - CustomPoint(x, y), - CustomPoint(x + 1, y), - CustomPoint(x + 1, y + 1), - CustomPoint(x, y + 1), + rotatedRectangleNW, + rotatedRectangleNE, + rotatedRectangleSE, + rotatedRectangleSW, ), + tile, )) { numberOfTiles++; + generatedTiles.add(tile.hashCode); foundOverlappingTile = true; } else if (foundOverlappingTile) { break; @@ -212,6 +242,72 @@ class TilesCounter { } } - return numberOfTiles; + return _trimToRange(region, numberOfTiles); + } + + /// Returns the number of tiles within a [DownloadableRegion] with generic type + /// [CustomPolygonRegion] + @internal + static int customPolygonTiles(DownloadableRegion region) { + region as DownloadableRegion; + + final customPolygonOutline = region.originalRegion.outline; + + int numberOfTiles = 0; + + for (double zoomLvl = region.minZoom.toDouble(); + zoomLvl <= region.maxZoom; + zoomLvl++) { + final allOutlineTiles = >{}; + + final pointsOutline = customPolygonOutline + .map((e) => region.crs.latLngToPoint(e, zoomLvl).floor()); + + for (final triangle in Earcut.triangulateFromPoints( + pointsOutline.map((e) => e.toDoublePoint()), + ).map(pointsOutline.elementAt).slices(3)) { + final outlineTiles = { + ..._bresenhamsLGA( + Point(triangle[0].x, triangle[0].y), + Point(triangle[1].x, triangle[1].y), + unscaleBy: region.options.tileSize, + ), + ..._bresenhamsLGA( + Point(triangle[1].x, triangle[1].y), + Point(triangle[2].x, triangle[2].y), + unscaleBy: region.options.tileSize, + ), + ..._bresenhamsLGA( + Point(triangle[2].x, triangle[2].y), + Point(triangle[0].x, triangle[0].y), + unscaleBy: region.options.tileSize, + ), + }; + allOutlineTiles.addAll(outlineTiles); + + final byY = >{}; + for (final Point(:x, :y) in outlineTiles) { + (byY[y] ??= {}).add(x); + } + + for (final xs in byY.values) { + final xsRawMin = xs.min; + int i = 0; + for (; xs.contains(xsRawMin + i); i++) {} + final xsMin = xsRawMin + i; + + final xsRawMax = xs.max; + i = 0; + for (; xs.contains(xsRawMax - i); i++) {} + final xsMax = xsRawMax - i; + + if (xsMin <= xsMax) numberOfTiles += (xsMax - xsMin) + 1; + } + } + + numberOfTiles += allOutlineTiles.length; + } + + return _trimToRange(region, numberOfTiles); } } diff --git a/lib/src/bulk_download/tile_loops/generate.dart b/lib/src/bulk_download/tile_loops/generate.dart index 4b7268c2..430491ca 100644 --- a/lib/src/bulk_download/tile_loops/generate.dart +++ b/lib/src/bulk_download/tile_loops/generate.dart @@ -3,35 +3,61 @@ part of 'shared.dart'; -class TilesGenerator { - static Future rectangleTiles(Map input) async { - final SendPort sendPort = input['sendPort']; - final DownloadableRegion region = input['region']; - final originalRegion = region.originalRegion as RectangleRegion; +/// A set of methods for each type of [BaseRegion] that generates the coordinates +/// of every tile within the specified [DownloadableRegion] +/// +/// Each method should handle a [DownloadableRegion] with a specific generic type +/// [BaseRegion]. If a method is passed a non-compatible region, it is expected +/// to throw a `CastError`. +/// +/// These methods must be run within seperate isolates, as they do heavy, +/// potentially lengthy computation. They do perform multiple-communication, +/// sending a new coordinate after they recieve a request message only. They will +/// 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. +/// +/// The number of tiles returned by each method must match the number of tiles +/// returned by the respective method in [TileCounters]. This is enforced by +/// automated tests. +@internal +class TileGenerators { + /// Generate the coordinates of each tile within a [DownloadableRegion] with + /// generic type [RectangleRegion] + @internal + static Future rectangleTiles( + ({SendPort sendPort, DownloadableRegion region}) input, + ) async { + final region = input.region as DownloadableRegion; + 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; - final tileSize = _getTileSize(region); - final northWest = originalRegion.bounds.northWest; - final southEast = originalRegion.bounds.southEast; - - final recievePort = ReceivePort(); - sendPort.send(recievePort.sendPort); - final requestQueue = StreamQueue(recievePort); - - for (int zoomLvl = region.minZoom; zoomLvl <= region.maxZoom; zoomLvl++) { - final CustomPoint nwCustomPoint = region.crs - .latLngToPoint(northWest, zoomLvl.toDouble()) - .unscaleBy(tileSize) + for (double zoomLvl = region.minZoom.toDouble(); + zoomLvl <= region.maxZoom; + zoomLvl++) { + final nwPoint = (region.crs.latLngToPoint(northWest, zoomLvl) / + region.options.tileSize) .floor(); - final CustomPoint seCustomPoint = region.crs - .latLngToPoint(southEast, zoomLvl.toDouble()) - .unscaleBy(tileSize) + final sePoint = (region.crs.latLngToPoint(southEast, zoomLvl) / + region.options.tileSize) .ceil() - - const CustomPoint(1, 1); + const Point(1, 1); - for (num x = nwCustomPoint.x; x <= seCustomPoint.x; x++) { - for (num y = nwCustomPoint.y; y <= seCustomPoint.y; y++) { + 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; await requestQueue.next; - sendPort.send([x.toInt(), y.toInt(), zoomLvl]); + input.sendPort.send((x, y, zoomLvl.toInt())); } } } @@ -39,57 +65,67 @@ class TilesGenerator { Isolate.exit(); } - static Future circleTiles(Map input) async { + /// 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 SendPort sendPort = input['sendPort']; - final DownloadableRegion region = input['region']; - - final tileSize = _getTileSize(region); + final region = input.region as DownloadableRegion; final circleOutline = region.originalRegion.toOutline(); - final recievePort = ReceivePort(); - sendPort.send(recievePort.sendPort); - final requestQueue = StreamQueue(recievePort); + final receivePort = ReceivePort(); + input.sendPort.send(receivePort.sendPort); + final requestQueue = StreamQueue(receivePort); // Format: Map>> final Map>> outlineTileNums = {}; + int tileCounter = -1; + final start = region.start - 1; + final end = (region.end ?? double.infinity) - 1; + for (int zoomLvl = region.minZoom; zoomLvl <= region.maxZoom; zoomLvl++) { - outlineTileNums[zoomLvl] = >{}; + outlineTileNums[zoomLvl] = {}; - for (final LatLng node in circleOutline) { - final CustomPoint tile = region.crs - .latLngToPoint(node, zoomLvl.toDouble()) - .unscaleBy(tileSize) + for (final node in circleOutline) { + final tile = (region.crs.latLngToPoint(node, zoomLvl.toDouble()) / + region.options.tileSize) .floor(); - outlineTileNums[zoomLvl]![tile.x.toInt()] ??= [ - 9223372036854775807, - -9223372036854775808, - ]; - - outlineTileNums[zoomLvl]![tile.x.toInt()] = [ - tile.y < outlineTileNums[zoomLvl]![tile.x.toInt()]![0] - ? tile.y.toInt() - : outlineTileNums[zoomLvl]![tile.x.toInt()]![0], - tile.y > outlineTileNums[zoomLvl]![tile.x.toInt()]![1] - ? tile.y.toInt() - : outlineTileNums[zoomLvl]![tile.x.toInt()]![1], + 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], ]; } - for (final int x in outlineTileNums[zoomLvl]!.keys) { + for (final x in outlineTileNums[zoomLvl]!.keys) { for (int y = outlineTileNums[zoomLvl]![x]![0]; y <= outlineTileNums[zoomLvl]![x]![1]; y++) { + tileCounter++; + if (tileCounter < start || tileCounter > end) continue; await requestQueue.next; - sendPort.send([x, y, zoomLvl]); + input.sendPort.send((x, y, zoomLvl)); } } } @@ -97,7 +133,12 @@ class TilesGenerator { Isolate.exit(); } - static Future lineTiles(Map input) async { + /// 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` @@ -111,31 +152,26 @@ class TilesGenerator { final _Polygon polygon = x == 0 ? a : b; for (int i1 = 0; i1 < polygon.points.length; i1++) { - final int i2 = (i1 + 1) % polygon.points.length; - final CustomPoint p1 = polygon.points[i1]; - final CustomPoint p2 = polygon.points[i2]; - - final CustomPoint normal = - CustomPoint(p2.y - p1.y, p1.x - p2.x); - - double minA = double.infinity; - double maxA = double.negativeInfinity; - - for (final CustomPoint p in a.points) { - final num projected = normal.x * p.x + normal.y * p.y; - - if (projected < minA) minA = projected.toDouble(); - if (projected > maxA) maxA = projected.toDouble(); + final i2 = (i1 + 1) % polygon.points.length; + final p1 = polygon.points[i1]; + final p2 = polygon.points[i2]; + + final normal = Point(p2.y - p1.y, p1.x - p2.x); + + var minA = largestInt; + var maxA = smallestInt; + for (final p in a.points) { + final projected = normal.x * p.x + normal.y * p.y; + if (projected < minA) minA = projected; + if (projected > maxA) maxA = projected; } - double minB = double.infinity; - double maxB = double.negativeInfinity; - - for (final CustomPoint p in b.points) { - final num projected = normal.x * p.x + normal.y * p.y; - - if (projected < minB) minB = projected.toDouble(); - if (projected > maxB) maxB = projected.toDouble(); + var minB = largestInt; + var maxB = smallestInt; + for (final p in b.points) { + final projected = normal.x * p.x + normal.y * p.y; + if (projected < minB) minB = projected; + if (projected > maxB) maxB = projected; } if (maxA < minB || maxB < minA) return false; @@ -145,88 +181,105 @@ class TilesGenerator { return true; } - final SendPort sendPort = input['sendPort']; - final DownloadableRegion region = input['region']; + final region = input.region as DownloadableRegion; + final lineOutline = region.originalRegion.toOutlines(1); - final tileSize = _getTileSize(region); - final lineOutline = (region.originalRegion as LineRegion).toOutlines(1); + final receivePort = ReceivePort(); + input.sendPort.send(receivePort.sendPort); + final requestQueue = StreamQueue(receivePort); - final recievePort = ReceivePort(); - sendPort.send(recievePort.sendPort); - final requestQueue = StreamQueue(recievePort); + int tileCounter = -1; + final start = region.start - 1; + final end = (region.end ?? double.infinity) - 1; for (double zoomLvl = region.minZoom.toDouble(); zoomLvl <= region.maxZoom; zoomLvl++) { - for (final List rect in lineOutline) { - final LatLng rrBottomLeft = rect[0]; - final LatLng rrBottomRight = rect[1]; - final LatLng rrTopRight = rect[2]; - final LatLng rrTopLeft = rect[3]; - - final List rrAllLat = [ - rrTopLeft.latitude, - rrTopRight.latitude, - rrBottomLeft.latitude, - rrBottomRight.latitude, + final generatedTiles = []; + + for (final rect in lineOutline) { + final rotatedRectangle = ( + bottomLeft: rect[0], + bottomRight: rect[1], + topRight: rect[2], + topLeft: rect[3], + ); + + final rotatedRectangleLats = [ + rotatedRectangle.topLeft.latitude, + rotatedRectangle.topRight.latitude, + rotatedRectangle.bottomLeft.latitude, + rotatedRectangle.bottomRight.latitude, ]; - final List rrAllLon = [ - rrTopLeft.longitude, - rrTopRight.longitude, - rrBottomLeft.longitude, - rrBottomRight.longitude, + final rotatedRectangleLngs = [ + rotatedRectangle.topLeft.longitude, + rotatedRectangle.topRight.longitude, + rotatedRectangle.bottomLeft.longitude, + rotatedRectangle.bottomRight.longitude, ]; - final CustomPoint rrNorthWest = region.crs - .latLngToPoint(rrTopLeft, zoomLvl) - .unscaleBy(tileSize) + final rotatedRectangleNW = + (region.crs.latLngToPoint(rotatedRectangle.topLeft, zoomLvl) / + region.options.tileSize) + .floor(); + final rotatedRectangleNE = + (region.crs.latLngToPoint(rotatedRectangle.topRight, zoomLvl) / + region.options.tileSize) + .ceil() - + const Point(1, 0); + final rotatedRectangleSW = + (region.crs.latLngToPoint(rotatedRectangle.bottomLeft, zoomLvl) / + region.options.tileSize) + .ceil() - + const Point(0, 1); + final rotatedRectangleSE = + (region.crs.latLngToPoint(rotatedRectangle.bottomRight, zoomLvl) / + region.options.tileSize) + .ceil() - + const Point(1, 1); + + final straightRectangleNW = (region.crs.latLngToPoint( + LatLng(rotatedRectangleLats.max, rotatedRectangleLngs.min), + zoomLvl, + ) / + region.options.tileSize) .floor(); - final CustomPoint rrNorthEast = region.crs - .latLngToPoint(rrTopRight, zoomLvl) - .unscaleBy(tileSize) - .ceil() - - const CustomPoint(1, 0); - final CustomPoint rrSouthWest = region.crs - .latLngToPoint(rrBottomLeft, zoomLvl) - .unscaleBy(tileSize) - .ceil() - - const CustomPoint(0, 1); - final CustomPoint rrSouthEast = region.crs - .latLngToPoint(rrBottomRight, zoomLvl) - .unscaleBy(tileSize) + final straightRectangleSE = (region.crs.latLngToPoint( + LatLng( + rotatedRectangleLats.min, + rotatedRectangleLngs.max, + ), + zoomLvl, + ) / + region.options.tileSize) .ceil() - - const CustomPoint(1, 1); + const Point(1, 1); - final CustomPoint srNorthWest = region.crs - .latLngToPoint(LatLng(rrAllLat.max, rrAllLon.min), zoomLvl) - .unscaleBy(tileSize) - .floor(); - final CustomPoint srSouthEast = region.crs - .latLngToPoint(LatLng(rrAllLat.min, rrAllLon.max), zoomLvl) - .unscaleBy(tileSize) - .ceil() - - const CustomPoint(1, 1); - - for (num x = srNorthWest.x; x <= srSouthEast.x; x++) { + for (int x = straightRectangleNW.x; x <= straightRectangleSE.x; x++) { bool foundOverlappingTile = false; - for (num y = srNorthWest.y; y <= srSouthEast.y; y++) { + 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), + Point(x + 1, y + 1), + Point(x, y + 1), + ); + if (generatedTiles.contains(tile.hashCode)) continue; if (overlap( _Polygon( - rrNorthWest, - rrNorthEast, - rrSouthEast, - rrSouthWest, - ), - _Polygon( - CustomPoint(x, y), - CustomPoint(x + 1, y), - CustomPoint(x + 1, y + 1), - CustomPoint(x, y + 1), + rotatedRectangleNW, + rotatedRectangleNE, + rotatedRectangleSE, + rotatedRectangleSW, ), + tile, )) { - await requestQueue.next; - sendPort.send([x.toInt(), y.toInt(), zoomLvl]); + generatedTiles.add(tile.hashCode); foundOverlappingTile = true; + await requestQueue.next; + input.sendPort.send((x, y, zoomLvl.toInt())); } else if (foundOverlappingTile) { break; } @@ -237,4 +290,85 @@ class TilesGenerator { 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); + + int tileCounter = -1; + final start = region.start - 1; + final end = (region.end ?? double.infinity) - 1; + + for (double zoomLvl = region.minZoom.toDouble(); + zoomLvl <= region.maxZoom; + zoomLvl++) { + final allOutlineTiles = >{}; + + final pointsOutline = customPolygonOutline + .map((e) => region.crs.latLngToPoint(e, zoomLvl).floor()); + + for (final triangle in Earcut.triangulateFromPoints( + pointsOutline.map((e) => e.toDoublePoint()), + ).map(pointsOutline.elementAt).slices(3)) { + final outlineTiles = { + ..._bresenhamsLGA( + Point(triangle[0].x, triangle[0].y), + Point(triangle[1].x, triangle[1].y), + unscaleBy: region.options.tileSize, + ), + ..._bresenhamsLGA( + Point(triangle[1].x, triangle[1].y), + Point(triangle[2].x, triangle[2].y), + unscaleBy: region.options.tileSize, + ), + ..._bresenhamsLGA( + Point(triangle[2].x, triangle[2].y), + Point(triangle[0].x, triangle[0].y), + unscaleBy: region.options.tileSize, + ), + }; + allOutlineTiles.addAll(outlineTiles); + + final byY = >{}; + for (final Point(:x, :y) in outlineTiles) { + (byY[y] ??= {}).add(x); + } + + for (final MapEntry(key: y, value: xs) in byY.entries) { + final xsRawMin = xs.min; + int i = 0; + for (; xs.contains(xsRawMin + i); i++) {} + final xsMin = xsRawMin + i; + + final xsRawMax = xs.max; + i = 0; + for (; xs.contains(xsRawMax - i); i++) {} + final xsMax = xsRawMax - i; + + for (int x = xsMin; x <= xsMax; x++) { + await requestQueue.next; + input.sendPort.send((x, y, zoomLvl.toInt())); + } + } + } + + for (final Point(:x, :y) in allOutlineTiles) { + tileCounter++; + if (tileCounter < start || tileCounter > end) continue; + await requestQueue.next; + input.sendPort.send((x, y, zoomLvl.toInt())); + } + } + + Isolate.exit(); + } } diff --git a/lib/src/bulk_download/tile_loops/shared.dart b/lib/src/bulk_download/tile_loops/shared.dart index 250ed591..e8f423ec 100644 --- a/lib/src/bulk_download/tile_loops/shared.dart +++ b/lib/src/bulk_download/tile_loops/shared.dart @@ -2,28 +2,81 @@ // A full license can be found at .\LICENSE import 'dart:isolate'; +import 'dart:math'; import 'package:async/async.dart'; import 'package:collection/collection.dart'; +import 'package:dart_earcut/dart_earcut.dart'; import 'package:flutter_map/flutter_map.dart'; 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'; part 'count.dart'; part 'generate.dart'; class _Polygon { - final CustomPoint nw; - final CustomPoint ne; - final CustomPoint se; - final CustomPoint sw; + _Polygon(Point nw, Point ne, Point se, Point sw) + : points = [nw, ne, se, sw] { + hashCode = Object.hashAll(points); + } - _Polygon(this.nw, this.ne, this.se, this.sw); + final List> points; - List> get points => [nw, ne, se, sw]; + @override + late final int hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is _Polygon && hashCode == other.hashCode); } -CustomPoint _getTileSize(DownloadableRegion region) => - CustomPoint(region.options.tileSize, region.options.tileSize); +/// Bresenham’s line generation algorithm, ported (with minor API differences) +/// from [anushaihalapathirana/Bresenham-line-drawing-algorithm](https://github.com/anushaihalapathirana/Bresenham-line-drawing-algorithm). +Iterable> _bresenhamsLGA( + Point start, + Point end, { + double unscaleBy = 1, +}) sync* { + final dx = end.x - start.x; + final dy = end.y - start.y; + final absdx = dx.abs(); + final absdy = dy.abs(); + + var x = start.x; + var y = start.y; + yield Point((x / unscaleBy).floor(), (y / unscaleBy).floor()); + + if (absdx > absdy) { + var d = 2 * absdy - absdx; + + for (var i = 0; i < absdx; i++) { + x = dx < 0 ? x - 1 : x + 1; + if (d < 0) { + d = d + 2 * absdy; + } else { + y = dy < 0 ? y - 1 : y + 1; + d = d + (2 * absdy - 2 * absdx); + } + yield Point((x / unscaleBy).floor(), (y / unscaleBy).floor()); + } + } else { + // case when slope is greater than or equals to 1 + var d = 2 * absdx - absdy; + + for (var i = 0; i < absdy; i++) { + y = dy < 0 ? y - 1 : y + 1; + if (d < 0) { + d = d + 2 * absdx; + } else { + x = dx < 0 ? x - 1 : x + 1; + d = d + (2 * absdx) - (2 * absdy); + } + yield Point((x / unscaleBy).floor(), (y / unscaleBy).floor()); + } + } +} diff --git a/lib/src/bulk_download/tile_progress.dart b/lib/src/bulk_download/tile_progress.dart deleted file mode 100644 index fd7f51ff..00000000 --- a/lib/src/bulk_download/tile_progress.dart +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'dart:typed_data'; - -import 'package:meta/meta.dart'; - -@internal -class TileProgress { - final String? failedUrl; - final Uint8List? tileImage; - - final bool wasSeaTile; - final bool wasExistingTile; - final int sizeBytes; - - final bool wasCancelOperation; - final List? bulkTileWriterResponse; - - TileProgress({ - required this.failedUrl, - required this.tileImage, - required this.wasSeaTile, - required this.wasExistingTile, - required this.wasCancelOperation, - required this.bulkTileWriterResponse, - }) : sizeBytes = tileImage?.lengthInBytes ?? 0; - - @override - String toString() => - 'Tile Progress Report (${failedUrl != null ? 'Failed' : 'Successful'}):\n - `failedUrl`: $failedUrl\n - Has `tileImage`: ${tileImage != null}\n - `wasSeaTile`: $wasSeaTile\n - `wasExistingTile`: $wasExistingTile\n - `sizeBytes`: $sizeBytes\n - `wasCancelOperation`: $wasCancelOperation\n - `bulkTileWriterResponse`: $bulkTileWriterResponse'; -} diff --git a/lib/src/db/defs/metadata.dart b/lib/src/db/defs/metadata.dart deleted file mode 100644 index 60e46656..00000000 --- a/lib/src/db/defs/metadata.dart +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:isar/isar.dart'; -import 'package:meta/meta.dart'; - -import '../tools.dart'; - -part 'metadata.g.dart'; - -@internal -@Collection(accessor: 'metadata') -class DbMetadata { - Id get id => DatabaseTools.hash(name); - - final String name; - final String data; - - DbMetadata({ - required this.name, - required this.data, - }); -} diff --git a/lib/src/db/defs/metadata.g.dart b/lib/src/db/defs/metadata.g.dart deleted file mode 100644 index 7275c698..00000000 --- a/lib/src/db/defs/metadata.g.dart +++ /dev/null @@ -1,606 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'metadata.dart'; - -// ************************************************************************** -// IsarCollectionGenerator -// ************************************************************************** - -// coverage:ignore-file -// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types - -extension GetDbMetadataCollection on Isar { - IsarCollection get metadata => this.collection(); -} - -const DbMetadataSchema = CollectionSchema( - name: r'DbMetadata', - id: -8585861370448577604, - properties: { - r'data': PropertySchema( - id: 0, - name: r'data', - type: IsarType.string, - ), - r'name': PropertySchema( - id: 1, - name: r'name', - type: IsarType.string, - ) - }, - estimateSize: _dbMetadataEstimateSize, - serialize: _dbMetadataSerialize, - deserialize: _dbMetadataDeserialize, - deserializeProp: _dbMetadataDeserializeProp, - idName: r'id', - indexes: {}, - links: {}, - embeddedSchemas: {}, - getId: _dbMetadataGetId, - getLinks: _dbMetadataGetLinks, - attach: _dbMetadataAttach, - version: '3.1.0+1', -); - -int _dbMetadataEstimateSize( - DbMetadata object, - List offsets, - Map> allOffsets, -) { - var bytesCount = offsets.last; - bytesCount += 3 + object.data.length * 3; - bytesCount += 3 + object.name.length * 3; - return bytesCount; -} - -void _dbMetadataSerialize( - DbMetadata object, - IsarWriter writer, - List offsets, - Map> allOffsets, -) { - writer.writeString(offsets[0], object.data); - writer.writeString(offsets[1], object.name); -} - -DbMetadata _dbMetadataDeserialize( - Id id, - IsarReader reader, - List offsets, - Map> allOffsets, -) { - final object = DbMetadata( - data: reader.readString(offsets[0]), - name: reader.readString(offsets[1]), - ); - return object; -} - -P _dbMetadataDeserializeProp

( - IsarReader reader, - int propertyId, - int offset, - Map> allOffsets, -) { - switch (propertyId) { - case 0: - return (reader.readString(offset)) as P; - case 1: - return (reader.readString(offset)) as P; - default: - throw IsarError('Unknown property with id $propertyId'); - } -} - -Id _dbMetadataGetId(DbMetadata object) { - return object.id; -} - -List> _dbMetadataGetLinks(DbMetadata object) { - return []; -} - -void _dbMetadataAttach(IsarCollection col, Id id, DbMetadata object) {} - -extension DbMetadataQueryWhereSort - on QueryBuilder { - QueryBuilder anyId() { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(const IdWhereClause.any()); - }); - } -} - -extension DbMetadataQueryWhere - on QueryBuilder { - QueryBuilder idEqualTo(Id id) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IdWhereClause.between( - lower: id, - upper: id, - )); - }); - } - - QueryBuilder idNotEqualTo(Id id) { - return QueryBuilder.apply(this, (query) { - if (query.whereSort == Sort.asc) { - return query - .addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: false), - ) - .addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: false), - ); - } else { - return query - .addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: false), - ) - .addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: false), - ); - } - }); - } - - QueryBuilder idGreaterThan(Id id, - {bool include = false}) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: include), - ); - }); - } - - QueryBuilder idLessThan(Id id, - {bool include = false}) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: include), - ); - }); - } - - QueryBuilder idBetween( - Id lowerId, - Id upperId, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IdWhereClause.between( - lower: lowerId, - includeLower: includeLower, - upper: upperId, - includeUpper: includeUpper, - )); - }); - } -} - -extension DbMetadataQueryFilter - on QueryBuilder { - QueryBuilder dataEqualTo( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'data', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder dataGreaterThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'data', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder dataLessThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'data', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder dataBetween( - String lower, - String upper, { - bool includeLower = true, - bool includeUpper = true, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'data', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder dataStartsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.startsWith( - property: r'data', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder dataEndsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.endsWith( - property: r'data', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder dataContains( - String value, - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.contains( - property: r'data', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder dataMatches( - String pattern, - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.matches( - property: r'data', - wildcard: pattern, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder dataIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'data', - value: '', - )); - }); - } - - QueryBuilder dataIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - property: r'data', - value: '', - )); - }); - } - - QueryBuilder idEqualTo( - Id value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'id', - value: value, - )); - }); - } - - QueryBuilder idGreaterThan( - Id value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'id', - value: value, - )); - }); - } - - QueryBuilder idLessThan( - Id value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'id', - value: value, - )); - }); - } - - QueryBuilder idBetween( - Id lower, - Id upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'id', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder nameEqualTo( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder nameGreaterThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder nameLessThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder nameBetween( - String lower, - String upper, { - bool includeLower = true, - bool includeUpper = true, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'name', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder nameStartsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.startsWith( - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder nameEndsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.endsWith( - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder nameContains( - String value, - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.contains( - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder nameMatches( - String pattern, - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.matches( - property: r'name', - wildcard: pattern, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder nameIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'name', - value: '', - )); - }); - } - - QueryBuilder nameIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - property: r'name', - value: '', - )); - }); - } -} - -extension DbMetadataQueryObject - on QueryBuilder {} - -extension DbMetadataQueryLinks - on QueryBuilder {} - -extension DbMetadataQuerySortBy - on QueryBuilder { - QueryBuilder sortByData() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'data', Sort.asc); - }); - } - - QueryBuilder sortByDataDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'data', Sort.desc); - }); - } - - QueryBuilder sortByName() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'name', Sort.asc); - }); - } - - QueryBuilder sortByNameDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'name', Sort.desc); - }); - } -} - -extension DbMetadataQuerySortThenBy - on QueryBuilder { - QueryBuilder thenByData() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'data', Sort.asc); - }); - } - - QueryBuilder thenByDataDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'data', Sort.desc); - }); - } - - QueryBuilder thenById() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'id', Sort.asc); - }); - } - - QueryBuilder thenByIdDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'id', Sort.desc); - }); - } - - QueryBuilder thenByName() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'name', Sort.asc); - }); - } - - QueryBuilder thenByNameDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'name', Sort.desc); - }); - } -} - -extension DbMetadataQueryWhereDistinct - on QueryBuilder { - QueryBuilder distinctByData( - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'data', caseSensitive: caseSensitive); - }); - } - - QueryBuilder distinctByName( - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'name', caseSensitive: caseSensitive); - }); - } -} - -extension DbMetadataQueryProperty - on QueryBuilder { - QueryBuilder idProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'id'); - }); - } - - QueryBuilder dataProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'data'); - }); - } - - QueryBuilder nameProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'name'); - }); - } -} diff --git a/lib/src/db/defs/recovery.dart b/lib/src/db/defs/recovery.dart deleted file mode 100644 index e58c169d..00000000 --- a/lib/src/db/defs/recovery.dart +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:isar/isar.dart'; -import 'package:meta/meta.dart'; - -import '../../../flutter_map_tile_caching.dart'; - -part 'recovery.g.dart'; - -@internal -@Collection(accessor: 'recovery') -class DbRecoverableRegion { - final Id id; - final String storeName; - final DateTime time; - @enumerated - final RegionType type; - - final byte minZoom; - final byte maxZoom; - - final short start; - final short? end; - - final byte parallelThreads; - final bool preventRedownload; - final bool seaTileRemoval; - - final float? nwLat; - final float? nwLng; - final float? seLat; - final float? seLng; - - final float? centerLat; - final float? centerLng; - final float? circleRadius; - - final List? linePointsLat; - final List? linePointsLng; - final float? lineRadius; - - DbRecoverableRegion({ - required this.id, - required this.storeName, - required this.time, - required this.type, - required this.minZoom, - required this.maxZoom, - required this.start, - this.end, - required this.parallelThreads, - required this.preventRedownload, - required this.seaTileRemoval, - this.nwLat, - this.nwLng, - this.seLat, - this.seLng, - this.centerLat, - this.centerLng, - this.circleRadius, - this.linePointsLat, - this.linePointsLng, - this.lineRadius, - }); -} diff --git a/lib/src/db/defs/recovery.g.dart b/lib/src/db/defs/recovery.g.dart deleted file mode 100644 index 614f2c1f..00000000 --- a/lib/src/db/defs/recovery.g.dart +++ /dev/null @@ -1,2831 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'recovery.dart'; - -// ************************************************************************** -// IsarCollectionGenerator -// ************************************************************************** - -// coverage:ignore-file -// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types - -extension GetDbRecoverableRegionCollection on Isar { - IsarCollection get recovery => this.collection(); -} - -const DbRecoverableRegionSchema = CollectionSchema( - name: r'DbRecoverableRegion', - id: -8117814053675000476, - properties: { - r'centerLat': PropertySchema( - id: 0, - name: r'centerLat', - type: IsarType.float, - ), - r'centerLng': PropertySchema( - id: 1, - name: r'centerLng', - type: IsarType.float, - ), - r'circleRadius': PropertySchema( - id: 2, - name: r'circleRadius', - type: IsarType.float, - ), - r'end': PropertySchema( - id: 3, - name: r'end', - type: IsarType.int, - ), - r'linePointsLat': PropertySchema( - id: 4, - name: r'linePointsLat', - type: IsarType.floatList, - ), - r'linePointsLng': PropertySchema( - id: 5, - name: r'linePointsLng', - type: IsarType.floatList, - ), - r'lineRadius': PropertySchema( - id: 6, - name: r'lineRadius', - type: IsarType.float, - ), - r'maxZoom': PropertySchema( - id: 7, - name: r'maxZoom', - type: IsarType.byte, - ), - r'minZoom': PropertySchema( - id: 8, - name: r'minZoom', - type: IsarType.byte, - ), - r'nwLat': PropertySchema( - id: 9, - name: r'nwLat', - type: IsarType.float, - ), - r'nwLng': PropertySchema( - id: 10, - name: r'nwLng', - type: IsarType.float, - ), - r'parallelThreads': PropertySchema( - id: 11, - name: r'parallelThreads', - type: IsarType.byte, - ), - r'preventRedownload': PropertySchema( - id: 12, - name: r'preventRedownload', - type: IsarType.bool, - ), - r'seLat': PropertySchema( - id: 13, - name: r'seLat', - type: IsarType.float, - ), - r'seLng': PropertySchema( - id: 14, - name: r'seLng', - type: IsarType.float, - ), - r'seaTileRemoval': PropertySchema( - id: 15, - name: r'seaTileRemoval', - type: IsarType.bool, - ), - r'start': PropertySchema( - id: 16, - name: r'start', - type: IsarType.int, - ), - r'storeName': PropertySchema( - id: 17, - name: r'storeName', - type: IsarType.string, - ), - r'time': PropertySchema( - id: 18, - name: r'time', - type: IsarType.dateTime, - ), - r'type': PropertySchema( - id: 19, - name: r'type', - type: IsarType.byte, - enumMap: _DbRecoverableRegiontypeEnumValueMap, - ) - }, - estimateSize: _dbRecoverableRegionEstimateSize, - serialize: _dbRecoverableRegionSerialize, - deserialize: _dbRecoverableRegionDeserialize, - deserializeProp: _dbRecoverableRegionDeserializeProp, - idName: r'id', - indexes: {}, - links: {}, - embeddedSchemas: {}, - getId: _dbRecoverableRegionGetId, - getLinks: _dbRecoverableRegionGetLinks, - attach: _dbRecoverableRegionAttach, - version: '3.1.0+1', -); - -int _dbRecoverableRegionEstimateSize( - DbRecoverableRegion object, - List offsets, - Map> allOffsets, -) { - var bytesCount = offsets.last; - { - final value = object.linePointsLat; - if (value != null) { - bytesCount += 3 + value.length * 4; - } - } - { - final value = object.linePointsLng; - if (value != null) { - bytesCount += 3 + value.length * 4; - } - } - bytesCount += 3 + object.storeName.length * 3; - return bytesCount; -} - -void _dbRecoverableRegionSerialize( - DbRecoverableRegion object, - IsarWriter writer, - List offsets, - Map> allOffsets, -) { - writer.writeFloat(offsets[0], object.centerLat); - writer.writeFloat(offsets[1], object.centerLng); - writer.writeFloat(offsets[2], object.circleRadius); - writer.writeInt(offsets[3], object.end); - writer.writeFloatList(offsets[4], object.linePointsLat); - writer.writeFloatList(offsets[5], object.linePointsLng); - writer.writeFloat(offsets[6], object.lineRadius); - writer.writeByte(offsets[7], object.maxZoom); - writer.writeByte(offsets[8], object.minZoom); - writer.writeFloat(offsets[9], object.nwLat); - writer.writeFloat(offsets[10], object.nwLng); - writer.writeByte(offsets[11], object.parallelThreads); - writer.writeBool(offsets[12], object.preventRedownload); - writer.writeFloat(offsets[13], object.seLat); - writer.writeFloat(offsets[14], object.seLng); - writer.writeBool(offsets[15], object.seaTileRemoval); - writer.writeInt(offsets[16], object.start); - writer.writeString(offsets[17], object.storeName); - writer.writeDateTime(offsets[18], object.time); - writer.writeByte(offsets[19], object.type.index); -} - -DbRecoverableRegion _dbRecoverableRegionDeserialize( - Id id, - IsarReader reader, - List offsets, - Map> allOffsets, -) { - final object = DbRecoverableRegion( - centerLat: reader.readFloatOrNull(offsets[0]), - centerLng: reader.readFloatOrNull(offsets[1]), - circleRadius: reader.readFloatOrNull(offsets[2]), - end: reader.readIntOrNull(offsets[3]), - id: id, - linePointsLat: reader.readFloatList(offsets[4]), - linePointsLng: reader.readFloatList(offsets[5]), - lineRadius: reader.readFloatOrNull(offsets[6]), - maxZoom: reader.readByte(offsets[7]), - minZoom: reader.readByte(offsets[8]), - nwLat: reader.readFloatOrNull(offsets[9]), - nwLng: reader.readFloatOrNull(offsets[10]), - parallelThreads: reader.readByte(offsets[11]), - preventRedownload: reader.readBool(offsets[12]), - seLat: reader.readFloatOrNull(offsets[13]), - seLng: reader.readFloatOrNull(offsets[14]), - seaTileRemoval: reader.readBool(offsets[15]), - start: reader.readInt(offsets[16]), - storeName: reader.readString(offsets[17]), - time: reader.readDateTime(offsets[18]), - type: _DbRecoverableRegiontypeValueEnumMap[ - reader.readByteOrNull(offsets[19])] ?? - RegionType.rectangle, - ); - return object; -} - -P _dbRecoverableRegionDeserializeProp

( - IsarReader reader, - int propertyId, - int offset, - Map> allOffsets, -) { - switch (propertyId) { - case 0: - return (reader.readFloatOrNull(offset)) as P; - case 1: - return (reader.readFloatOrNull(offset)) as P; - case 2: - return (reader.readFloatOrNull(offset)) as P; - case 3: - return (reader.readIntOrNull(offset)) as P; - case 4: - return (reader.readFloatList(offset)) as P; - case 5: - return (reader.readFloatList(offset)) as P; - case 6: - return (reader.readFloatOrNull(offset)) as P; - case 7: - return (reader.readByte(offset)) as P; - case 8: - return (reader.readByte(offset)) as P; - case 9: - return (reader.readFloatOrNull(offset)) as P; - case 10: - return (reader.readFloatOrNull(offset)) as P; - case 11: - return (reader.readByte(offset)) as P; - case 12: - return (reader.readBool(offset)) as P; - case 13: - return (reader.readFloatOrNull(offset)) as P; - case 14: - return (reader.readFloatOrNull(offset)) as P; - case 15: - return (reader.readBool(offset)) as P; - case 16: - return (reader.readInt(offset)) as P; - case 17: - return (reader.readString(offset)) as P; - case 18: - return (reader.readDateTime(offset)) as P; - case 19: - return (_DbRecoverableRegiontypeValueEnumMap[ - reader.readByteOrNull(offset)] ?? - RegionType.rectangle) as P; - default: - throw IsarError('Unknown property with id $propertyId'); - } -} - -const _DbRecoverableRegiontypeEnumValueMap = { - 'rectangle': 0, - 'circle': 1, - 'line': 2, -}; -const _DbRecoverableRegiontypeValueEnumMap = { - 0: RegionType.rectangle, - 1: RegionType.circle, - 2: RegionType.line, -}; - -Id _dbRecoverableRegionGetId(DbRecoverableRegion object) { - return object.id; -} - -List> _dbRecoverableRegionGetLinks( - DbRecoverableRegion object) { - return []; -} - -void _dbRecoverableRegionAttach( - IsarCollection col, Id id, DbRecoverableRegion object) {} - -extension DbRecoverableRegionQueryWhereSort - on QueryBuilder { - QueryBuilder anyId() { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(const IdWhereClause.any()); - }); - } -} - -extension DbRecoverableRegionQueryWhere - on QueryBuilder { - QueryBuilder - idEqualTo(Id id) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IdWhereClause.between( - lower: id, - upper: id, - )); - }); - } - - QueryBuilder - idNotEqualTo(Id id) { - return QueryBuilder.apply(this, (query) { - if (query.whereSort == Sort.asc) { - return query - .addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: false), - ) - .addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: false), - ); - } else { - return query - .addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: false), - ) - .addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: false), - ); - } - }); - } - - QueryBuilder - idGreaterThan(Id id, {bool include = false}) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: include), - ); - }); - } - - QueryBuilder - idLessThan(Id id, {bool include = false}) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: include), - ); - }); - } - - QueryBuilder - idBetween( - Id lowerId, - Id upperId, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IdWhereClause.between( - lower: lowerId, - includeLower: includeLower, - upper: upperId, - includeUpper: includeUpper, - )); - }); - } -} - -extension DbRecoverableRegionQueryFilter on QueryBuilder { - QueryBuilder - centerLatIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'centerLat', - )); - }); - } - - QueryBuilder - centerLatIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'centerLat', - )); - }); - } - - QueryBuilder - centerLatEqualTo( - double? value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'centerLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - centerLatGreaterThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'centerLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - centerLatLessThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'centerLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - centerLatBetween( - double? lower, - double? upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'centerLat', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - centerLngIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'centerLng', - )); - }); - } - - QueryBuilder - centerLngIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'centerLng', - )); - }); - } - - QueryBuilder - centerLngEqualTo( - double? value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'centerLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - centerLngGreaterThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'centerLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - centerLngLessThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'centerLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - centerLngBetween( - double? lower, - double? upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'centerLng', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - circleRadiusIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'circleRadius', - )); - }); - } - - QueryBuilder - circleRadiusIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'circleRadius', - )); - }); - } - - QueryBuilder - circleRadiusEqualTo( - double? value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'circleRadius', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - circleRadiusGreaterThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'circleRadius', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - circleRadiusLessThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'circleRadius', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - circleRadiusBetween( - double? lower, - double? upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'circleRadius', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - endIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'end', - )); - }); - } - - QueryBuilder - endIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'end', - )); - }); - } - - QueryBuilder - endEqualTo(int? value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'end', - value: value, - )); - }); - } - - QueryBuilder - endGreaterThan( - int? value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'end', - value: value, - )); - }); - } - - QueryBuilder - endLessThan( - int? value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'end', - value: value, - )); - }); - } - - QueryBuilder - endBetween( - int? lower, - int? upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'end', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - idEqualTo(Id value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'id', - value: value, - )); - }); - } - - QueryBuilder - idGreaterThan( - Id value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'id', - value: value, - )); - }); - } - - QueryBuilder - idLessThan( - Id value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'id', - value: value, - )); - }); - } - - QueryBuilder - idBetween( - Id lower, - Id upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'id', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - linePointsLatIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'linePointsLat', - )); - }); - } - - QueryBuilder - linePointsLatIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'linePointsLat', - )); - }); - } - - QueryBuilder - linePointsLatElementEqualTo( - double value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'linePointsLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - linePointsLatElementGreaterThan( - double value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'linePointsLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - linePointsLatElementLessThan( - double value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'linePointsLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - linePointsLatElementBetween( - double lower, - double upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'linePointsLat', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - linePointsLatLengthEqualTo(int length) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLat', - length, - true, - length, - true, - ); - }); - } - - QueryBuilder - linePointsLatIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLat', - 0, - true, - 0, - true, - ); - }); - } - - QueryBuilder - linePointsLatIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLat', - 0, - false, - 999999, - true, - ); - }); - } - - QueryBuilder - linePointsLatLengthLessThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLat', - 0, - true, - length, - include, - ); - }); - } - - QueryBuilder - linePointsLatLengthGreaterThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLat', - length, - include, - 999999, - true, - ); - }); - } - - QueryBuilder - linePointsLatLengthBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLat', - lower, - includeLower, - upper, - includeUpper, - ); - }); - } - - QueryBuilder - linePointsLngIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'linePointsLng', - )); - }); - } - - QueryBuilder - linePointsLngIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'linePointsLng', - )); - }); - } - - QueryBuilder - linePointsLngElementEqualTo( - double value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'linePointsLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - linePointsLngElementGreaterThan( - double value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'linePointsLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - linePointsLngElementLessThan( - double value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'linePointsLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - linePointsLngElementBetween( - double lower, - double upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'linePointsLng', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - linePointsLngLengthEqualTo(int length) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLng', - length, - true, - length, - true, - ); - }); - } - - QueryBuilder - linePointsLngIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLng', - 0, - true, - 0, - true, - ); - }); - } - - QueryBuilder - linePointsLngIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLng', - 0, - false, - 999999, - true, - ); - }); - } - - QueryBuilder - linePointsLngLengthLessThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLng', - 0, - true, - length, - include, - ); - }); - } - - QueryBuilder - linePointsLngLengthGreaterThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLng', - length, - include, - 999999, - true, - ); - }); - } - - QueryBuilder - linePointsLngLengthBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'linePointsLng', - lower, - includeLower, - upper, - includeUpper, - ); - }); - } - - QueryBuilder - lineRadiusIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'lineRadius', - )); - }); - } - - QueryBuilder - lineRadiusIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'lineRadius', - )); - }); - } - - QueryBuilder - lineRadiusEqualTo( - double? value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'lineRadius', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - lineRadiusGreaterThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'lineRadius', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - lineRadiusLessThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'lineRadius', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - lineRadiusBetween( - double? lower, - double? upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'lineRadius', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - maxZoomEqualTo(int value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'maxZoom', - value: value, - )); - }); - } - - QueryBuilder - maxZoomGreaterThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'maxZoom', - value: value, - )); - }); - } - - QueryBuilder - maxZoomLessThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'maxZoom', - value: value, - )); - }); - } - - QueryBuilder - maxZoomBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'maxZoom', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - minZoomEqualTo(int value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'minZoom', - value: value, - )); - }); - } - - QueryBuilder - minZoomGreaterThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'minZoom', - value: value, - )); - }); - } - - QueryBuilder - minZoomLessThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'minZoom', - value: value, - )); - }); - } - - QueryBuilder - minZoomBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'minZoom', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - nwLatIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'nwLat', - )); - }); - } - - QueryBuilder - nwLatIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'nwLat', - )); - }); - } - - QueryBuilder - nwLatEqualTo( - double? value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'nwLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - nwLatGreaterThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'nwLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - nwLatLessThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'nwLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - nwLatBetween( - double? lower, - double? upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'nwLat', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - nwLngIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'nwLng', - )); - }); - } - - QueryBuilder - nwLngIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'nwLng', - )); - }); - } - - QueryBuilder - nwLngEqualTo( - double? value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'nwLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - nwLngGreaterThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'nwLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - nwLngLessThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'nwLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - nwLngBetween( - double? lower, - double? upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'nwLng', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - parallelThreadsEqualTo(int value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'parallelThreads', - value: value, - )); - }); - } - - QueryBuilder - parallelThreadsGreaterThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'parallelThreads', - value: value, - )); - }); - } - - QueryBuilder - parallelThreadsLessThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'parallelThreads', - value: value, - )); - }); - } - - QueryBuilder - parallelThreadsBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'parallelThreads', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - preventRedownloadEqualTo(bool value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'preventRedownload', - value: value, - )); - }); - } - - QueryBuilder - seLatIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'seLat', - )); - }); - } - - QueryBuilder - seLatIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'seLat', - )); - }); - } - - QueryBuilder - seLatEqualTo( - double? value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'seLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - seLatGreaterThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'seLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - seLatLessThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'seLat', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - seLatBetween( - double? lower, - double? upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'seLat', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - seLngIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'seLng', - )); - }); - } - - QueryBuilder - seLngIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'seLng', - )); - }); - } - - QueryBuilder - seLngEqualTo( - double? value, { - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'seLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - seLngGreaterThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'seLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - seLngLessThan( - double? value, { - bool include = false, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'seLng', - value: value, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - seLngBetween( - double? lower, - double? upper, { - bool includeLower = true, - bool includeUpper = true, - double epsilon = Query.epsilon, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'seLng', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - epsilon: epsilon, - )); - }); - } - - QueryBuilder - seaTileRemovalEqualTo(bool value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'seaTileRemoval', - value: value, - )); - }); - } - - QueryBuilder - startEqualTo(int value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'start', - value: value, - )); - }); - } - - QueryBuilder - startGreaterThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'start', - value: value, - )); - }); - } - - QueryBuilder - startLessThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'start', - value: value, - )); - }); - } - - QueryBuilder - startBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'start', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - storeNameEqualTo( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'storeName', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - storeNameGreaterThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'storeName', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - storeNameLessThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'storeName', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - storeNameBetween( - String lower, - String upper, { - bool includeLower = true, - bool includeUpper = true, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'storeName', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - storeNameStartsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.startsWith( - property: r'storeName', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - storeNameEndsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.endsWith( - property: r'storeName', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - storeNameContains(String value, {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.contains( - property: r'storeName', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - storeNameMatches(String pattern, {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.matches( - property: r'storeName', - wildcard: pattern, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - storeNameIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'storeName', - value: '', - )); - }); - } - - QueryBuilder - storeNameIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - property: r'storeName', - value: '', - )); - }); - } - - QueryBuilder - timeEqualTo(DateTime value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'time', - value: value, - )); - }); - } - - QueryBuilder - timeGreaterThan( - DateTime value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'time', - value: value, - )); - }); - } - - QueryBuilder - timeLessThan( - DateTime value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'time', - value: value, - )); - }); - } - - QueryBuilder - timeBetween( - DateTime lower, - DateTime upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'time', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - typeEqualTo(RegionType value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'type', - value: value, - )); - }); - } - - QueryBuilder - typeGreaterThan( - RegionType value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'type', - value: value, - )); - }); - } - - QueryBuilder - typeLessThan( - RegionType value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'type', - value: value, - )); - }); - } - - QueryBuilder - typeBetween( - RegionType lower, - RegionType upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'type', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } -} - -extension DbRecoverableRegionQueryObject on QueryBuilder {} - -extension DbRecoverableRegionQueryLinks on QueryBuilder {} - -extension DbRecoverableRegionQuerySortBy - on QueryBuilder { - QueryBuilder - sortByCenterLat() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'centerLat', Sort.asc); - }); - } - - QueryBuilder - sortByCenterLatDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'centerLat', Sort.desc); - }); - } - - QueryBuilder - sortByCenterLng() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'centerLng', Sort.asc); - }); - } - - QueryBuilder - sortByCenterLngDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'centerLng', Sort.desc); - }); - } - - QueryBuilder - sortByCircleRadius() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'circleRadius', Sort.asc); - }); - } - - QueryBuilder - sortByCircleRadiusDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'circleRadius', Sort.desc); - }); - } - - QueryBuilder - sortByEnd() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'end', Sort.asc); - }); - } - - QueryBuilder - sortByEndDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'end', Sort.desc); - }); - } - - QueryBuilder - sortByLineRadius() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'lineRadius', Sort.asc); - }); - } - - QueryBuilder - sortByLineRadiusDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'lineRadius', Sort.desc); - }); - } - - QueryBuilder - sortByMaxZoom() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'maxZoom', Sort.asc); - }); - } - - QueryBuilder - sortByMaxZoomDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'maxZoom', Sort.desc); - }); - } - - QueryBuilder - sortByMinZoom() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'minZoom', Sort.asc); - }); - } - - QueryBuilder - sortByMinZoomDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'minZoom', Sort.desc); - }); - } - - QueryBuilder - sortByNwLat() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'nwLat', Sort.asc); - }); - } - - QueryBuilder - sortByNwLatDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'nwLat', Sort.desc); - }); - } - - QueryBuilder - sortByNwLng() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'nwLng', Sort.asc); - }); - } - - QueryBuilder - sortByNwLngDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'nwLng', Sort.desc); - }); - } - - QueryBuilder - sortByParallelThreads() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'parallelThreads', Sort.asc); - }); - } - - QueryBuilder - sortByParallelThreadsDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'parallelThreads', Sort.desc); - }); - } - - QueryBuilder - sortByPreventRedownload() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'preventRedownload', Sort.asc); - }); - } - - QueryBuilder - sortByPreventRedownloadDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'preventRedownload', Sort.desc); - }); - } - - QueryBuilder - sortBySeLat() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seLat', Sort.asc); - }); - } - - QueryBuilder - sortBySeLatDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seLat', Sort.desc); - }); - } - - QueryBuilder - sortBySeLng() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seLng', Sort.asc); - }); - } - - QueryBuilder - sortBySeLngDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seLng', Sort.desc); - }); - } - - QueryBuilder - sortBySeaTileRemoval() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seaTileRemoval', Sort.asc); - }); - } - - QueryBuilder - sortBySeaTileRemovalDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seaTileRemoval', Sort.desc); - }); - } - - QueryBuilder - sortByStart() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'start', Sort.asc); - }); - } - - QueryBuilder - sortByStartDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'start', Sort.desc); - }); - } - - QueryBuilder - sortByStoreName() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'storeName', Sort.asc); - }); - } - - QueryBuilder - sortByStoreNameDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'storeName', Sort.desc); - }); - } - - QueryBuilder - sortByTime() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'time', Sort.asc); - }); - } - - QueryBuilder - sortByTimeDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'time', Sort.desc); - }); - } - - QueryBuilder - sortByType() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'type', Sort.asc); - }); - } - - QueryBuilder - sortByTypeDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'type', Sort.desc); - }); - } -} - -extension DbRecoverableRegionQuerySortThenBy - on QueryBuilder { - QueryBuilder - thenByCenterLat() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'centerLat', Sort.asc); - }); - } - - QueryBuilder - thenByCenterLatDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'centerLat', Sort.desc); - }); - } - - QueryBuilder - thenByCenterLng() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'centerLng', Sort.asc); - }); - } - - QueryBuilder - thenByCenterLngDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'centerLng', Sort.desc); - }); - } - - QueryBuilder - thenByCircleRadius() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'circleRadius', Sort.asc); - }); - } - - QueryBuilder - thenByCircleRadiusDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'circleRadius', Sort.desc); - }); - } - - QueryBuilder - thenByEnd() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'end', Sort.asc); - }); - } - - QueryBuilder - thenByEndDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'end', Sort.desc); - }); - } - - QueryBuilder - thenById() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'id', Sort.asc); - }); - } - - QueryBuilder - thenByIdDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'id', Sort.desc); - }); - } - - QueryBuilder - thenByLineRadius() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'lineRadius', Sort.asc); - }); - } - - QueryBuilder - thenByLineRadiusDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'lineRadius', Sort.desc); - }); - } - - QueryBuilder - thenByMaxZoom() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'maxZoom', Sort.asc); - }); - } - - QueryBuilder - thenByMaxZoomDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'maxZoom', Sort.desc); - }); - } - - QueryBuilder - thenByMinZoom() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'minZoom', Sort.asc); - }); - } - - QueryBuilder - thenByMinZoomDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'minZoom', Sort.desc); - }); - } - - QueryBuilder - thenByNwLat() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'nwLat', Sort.asc); - }); - } - - QueryBuilder - thenByNwLatDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'nwLat', Sort.desc); - }); - } - - QueryBuilder - thenByNwLng() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'nwLng', Sort.asc); - }); - } - - QueryBuilder - thenByNwLngDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'nwLng', Sort.desc); - }); - } - - QueryBuilder - thenByParallelThreads() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'parallelThreads', Sort.asc); - }); - } - - QueryBuilder - thenByParallelThreadsDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'parallelThreads', Sort.desc); - }); - } - - QueryBuilder - thenByPreventRedownload() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'preventRedownload', Sort.asc); - }); - } - - QueryBuilder - thenByPreventRedownloadDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'preventRedownload', Sort.desc); - }); - } - - QueryBuilder - thenBySeLat() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seLat', Sort.asc); - }); - } - - QueryBuilder - thenBySeLatDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seLat', Sort.desc); - }); - } - - QueryBuilder - thenBySeLng() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seLng', Sort.asc); - }); - } - - QueryBuilder - thenBySeLngDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seLng', Sort.desc); - }); - } - - QueryBuilder - thenBySeaTileRemoval() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seaTileRemoval', Sort.asc); - }); - } - - QueryBuilder - thenBySeaTileRemovalDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'seaTileRemoval', Sort.desc); - }); - } - - QueryBuilder - thenByStart() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'start', Sort.asc); - }); - } - - QueryBuilder - thenByStartDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'start', Sort.desc); - }); - } - - QueryBuilder - thenByStoreName() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'storeName', Sort.asc); - }); - } - - QueryBuilder - thenByStoreNameDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'storeName', Sort.desc); - }); - } - - QueryBuilder - thenByTime() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'time', Sort.asc); - }); - } - - QueryBuilder - thenByTimeDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'time', Sort.desc); - }); - } - - QueryBuilder - thenByType() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'type', Sort.asc); - }); - } - - QueryBuilder - thenByTypeDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'type', Sort.desc); - }); - } -} - -extension DbRecoverableRegionQueryWhereDistinct - on QueryBuilder { - QueryBuilder - distinctByCenterLat() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'centerLat'); - }); - } - - QueryBuilder - distinctByCenterLng() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'centerLng'); - }); - } - - QueryBuilder - distinctByCircleRadius() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'circleRadius'); - }); - } - - QueryBuilder - distinctByEnd() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'end'); - }); - } - - QueryBuilder - distinctByLinePointsLat() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'linePointsLat'); - }); - } - - QueryBuilder - distinctByLinePointsLng() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'linePointsLng'); - }); - } - - QueryBuilder - distinctByLineRadius() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'lineRadius'); - }); - } - - QueryBuilder - distinctByMaxZoom() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'maxZoom'); - }); - } - - QueryBuilder - distinctByMinZoom() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'minZoom'); - }); - } - - QueryBuilder - distinctByNwLat() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'nwLat'); - }); - } - - QueryBuilder - distinctByNwLng() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'nwLng'); - }); - } - - QueryBuilder - distinctByParallelThreads() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'parallelThreads'); - }); - } - - QueryBuilder - distinctByPreventRedownload() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'preventRedownload'); - }); - } - - QueryBuilder - distinctBySeLat() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'seLat'); - }); - } - - QueryBuilder - distinctBySeLng() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'seLng'); - }); - } - - QueryBuilder - distinctBySeaTileRemoval() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'seaTileRemoval'); - }); - } - - QueryBuilder - distinctByStart() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'start'); - }); - } - - QueryBuilder - distinctByStoreName({bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'storeName', caseSensitive: caseSensitive); - }); - } - - QueryBuilder - distinctByTime() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'time'); - }); - } - - QueryBuilder - distinctByType() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'type'); - }); - } -} - -extension DbRecoverableRegionQueryProperty - on QueryBuilder { - QueryBuilder idProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'id'); - }); - } - - QueryBuilder - centerLatProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'centerLat'); - }); - } - - QueryBuilder - centerLngProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'centerLng'); - }); - } - - QueryBuilder - circleRadiusProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'circleRadius'); - }); - } - - QueryBuilder endProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'end'); - }); - } - - QueryBuilder?, QQueryOperations> - linePointsLatProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'linePointsLat'); - }); - } - - QueryBuilder?, QQueryOperations> - linePointsLngProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'linePointsLng'); - }); - } - - QueryBuilder - lineRadiusProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'lineRadius'); - }); - } - - QueryBuilder maxZoomProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'maxZoom'); - }); - } - - QueryBuilder minZoomProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'minZoom'); - }); - } - - QueryBuilder nwLatProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'nwLat'); - }); - } - - QueryBuilder nwLngProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'nwLng'); - }); - } - - QueryBuilder - parallelThreadsProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'parallelThreads'); - }); - } - - QueryBuilder - preventRedownloadProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'preventRedownload'); - }); - } - - QueryBuilder seLatProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'seLat'); - }); - } - - QueryBuilder seLngProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'seLng'); - }); - } - - QueryBuilder - seaTileRemovalProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'seaTileRemoval'); - }); - } - - QueryBuilder startProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'start'); - }); - } - - QueryBuilder - storeNameProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'storeName'); - }); - } - - QueryBuilder timeProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'time'); - }); - } - - QueryBuilder - typeProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'type'); - }); - } -} diff --git a/lib/src/db/defs/store_descriptor.dart b/lib/src/db/defs/store_descriptor.dart deleted file mode 100644 index 5977b398..00000000 --- a/lib/src/db/defs/store_descriptor.dart +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:isar/isar.dart'; -import 'package:meta/meta.dart'; - -part 'store_descriptor.g.dart'; - -@internal -@Collection(accessor: 'storeDescriptor') -class DbStoreDescriptor { - final Id id = 0; - final String name; - - int hits = 0; - int misses = 0; - - DbStoreDescriptor({required this.name}); -} diff --git a/lib/src/db/defs/store_descriptor.g.dart b/lib/src/db/defs/store_descriptor.g.dart deleted file mode 100644 index baff9402..00000000 --- a/lib/src/db/defs/store_descriptor.g.dart +++ /dev/null @@ -1,660 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'store_descriptor.dart'; - -// ************************************************************************** -// IsarCollectionGenerator -// ************************************************************************** - -// coverage:ignore-file -// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types - -extension GetDbStoreDescriptorCollection on Isar { - IsarCollection get storeDescriptor => this.collection(); -} - -const DbStoreDescriptorSchema = CollectionSchema( - name: r'DbStoreDescriptor', - id: 1365152130637522244, - properties: { - r'hits': PropertySchema( - id: 0, - name: r'hits', - type: IsarType.long, - ), - r'misses': PropertySchema( - id: 1, - name: r'misses', - type: IsarType.long, - ), - r'name': PropertySchema( - id: 2, - name: r'name', - type: IsarType.string, - ) - }, - estimateSize: _dbStoreDescriptorEstimateSize, - serialize: _dbStoreDescriptorSerialize, - deserialize: _dbStoreDescriptorDeserialize, - deserializeProp: _dbStoreDescriptorDeserializeProp, - idName: r'id', - indexes: {}, - links: {}, - embeddedSchemas: {}, - getId: _dbStoreDescriptorGetId, - getLinks: _dbStoreDescriptorGetLinks, - attach: _dbStoreDescriptorAttach, - version: '3.1.0+1', -); - -int _dbStoreDescriptorEstimateSize( - DbStoreDescriptor object, - List offsets, - Map> allOffsets, -) { - var bytesCount = offsets.last; - bytesCount += 3 + object.name.length * 3; - return bytesCount; -} - -void _dbStoreDescriptorSerialize( - DbStoreDescriptor object, - IsarWriter writer, - List offsets, - Map> allOffsets, -) { - writer.writeLong(offsets[0], object.hits); - writer.writeLong(offsets[1], object.misses); - writer.writeString(offsets[2], object.name); -} - -DbStoreDescriptor _dbStoreDescriptorDeserialize( - Id id, - IsarReader reader, - List offsets, - Map> allOffsets, -) { - final object = DbStoreDescriptor( - name: reader.readString(offsets[2]), - ); - object.hits = reader.readLong(offsets[0]); - object.misses = reader.readLong(offsets[1]); - return object; -} - -P _dbStoreDescriptorDeserializeProp

( - IsarReader reader, - int propertyId, - int offset, - Map> allOffsets, -) { - switch (propertyId) { - case 0: - return (reader.readLong(offset)) as P; - case 1: - return (reader.readLong(offset)) as P; - case 2: - return (reader.readString(offset)) as P; - default: - throw IsarError('Unknown property with id $propertyId'); - } -} - -Id _dbStoreDescriptorGetId(DbStoreDescriptor object) { - return object.id; -} - -List> _dbStoreDescriptorGetLinks( - DbStoreDescriptor object) { - return []; -} - -void _dbStoreDescriptorAttach( - IsarCollection col, Id id, DbStoreDescriptor object) {} - -extension DbStoreDescriptorQueryWhereSort - on QueryBuilder { - QueryBuilder anyId() { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(const IdWhereClause.any()); - }); - } -} - -extension DbStoreDescriptorQueryWhere - on QueryBuilder { - QueryBuilder - idEqualTo(Id id) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IdWhereClause.between( - lower: id, - upper: id, - )); - }); - } - - QueryBuilder - idNotEqualTo(Id id) { - return QueryBuilder.apply(this, (query) { - if (query.whereSort == Sort.asc) { - return query - .addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: false), - ) - .addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: false), - ); - } else { - return query - .addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: false), - ) - .addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: false), - ); - } - }); - } - - QueryBuilder - idGreaterThan(Id id, {bool include = false}) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: include), - ); - }); - } - - QueryBuilder - idLessThan(Id id, {bool include = false}) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: include), - ); - }); - } - - QueryBuilder - idBetween( - Id lowerId, - Id upperId, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IdWhereClause.between( - lower: lowerId, - includeLower: includeLower, - upper: upperId, - includeUpper: includeUpper, - )); - }); - } -} - -extension DbStoreDescriptorQueryFilter - on QueryBuilder { - QueryBuilder - hitsEqualTo(int value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'hits', - value: value, - )); - }); - } - - QueryBuilder - hitsGreaterThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'hits', - value: value, - )); - }); - } - - QueryBuilder - hitsLessThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'hits', - value: value, - )); - }); - } - - QueryBuilder - hitsBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'hits', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - idEqualTo(Id value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'id', - value: value, - )); - }); - } - - QueryBuilder - idGreaterThan( - Id value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'id', - value: value, - )); - }); - } - - QueryBuilder - idLessThan( - Id value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'id', - value: value, - )); - }); - } - - QueryBuilder - idBetween( - Id lower, - Id upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'id', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - missesEqualTo(int value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'misses', - value: value, - )); - }); - } - - QueryBuilder - missesGreaterThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'misses', - value: value, - )); - }); - } - - QueryBuilder - missesLessThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'misses', - value: value, - )); - }); - } - - QueryBuilder - missesBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'misses', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder - nameEqualTo( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - nameGreaterThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - nameLessThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - nameBetween( - String lower, - String upper, { - bool includeLower = true, - bool includeUpper = true, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'name', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - nameStartsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.startsWith( - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - nameEndsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.endsWith( - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - nameContains(String value, {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.contains( - property: r'name', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - nameMatches(String pattern, {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.matches( - property: r'name', - wildcard: pattern, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - nameIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'name', - value: '', - )); - }); - } - - QueryBuilder - nameIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - property: r'name', - value: '', - )); - }); - } -} - -extension DbStoreDescriptorQueryObject - on QueryBuilder {} - -extension DbStoreDescriptorQueryLinks - on QueryBuilder {} - -extension DbStoreDescriptorQuerySortBy - on QueryBuilder { - QueryBuilder - sortByHits() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'hits', Sort.asc); - }); - } - - QueryBuilder - sortByHitsDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'hits', Sort.desc); - }); - } - - QueryBuilder - sortByMisses() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'misses', Sort.asc); - }); - } - - QueryBuilder - sortByMissesDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'misses', Sort.desc); - }); - } - - QueryBuilder - sortByName() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'name', Sort.asc); - }); - } - - QueryBuilder - sortByNameDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'name', Sort.desc); - }); - } -} - -extension DbStoreDescriptorQuerySortThenBy - on QueryBuilder { - QueryBuilder - thenByHits() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'hits', Sort.asc); - }); - } - - QueryBuilder - thenByHitsDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'hits', Sort.desc); - }); - } - - QueryBuilder thenById() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'id', Sort.asc); - }); - } - - QueryBuilder - thenByIdDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'id', Sort.desc); - }); - } - - QueryBuilder - thenByMisses() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'misses', Sort.asc); - }); - } - - QueryBuilder - thenByMissesDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'misses', Sort.desc); - }); - } - - QueryBuilder - thenByName() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'name', Sort.asc); - }); - } - - QueryBuilder - thenByNameDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'name', Sort.desc); - }); - } -} - -extension DbStoreDescriptorQueryWhereDistinct - on QueryBuilder { - QueryBuilder - distinctByHits() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'hits'); - }); - } - - QueryBuilder - distinctByMisses() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'misses'); - }); - } - - QueryBuilder distinctByName( - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'name', caseSensitive: caseSensitive); - }); - } -} - -extension DbStoreDescriptorQueryProperty - on QueryBuilder { - QueryBuilder idProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'id'); - }); - } - - QueryBuilder hitsProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'hits'); - }); - } - - QueryBuilder missesProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'misses'); - }); - } - - QueryBuilder nameProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'name'); - }); - } -} diff --git a/lib/src/db/defs/tile.dart b/lib/src/db/defs/tile.dart deleted file mode 100644 index 79c7ffe9..00000000 --- a/lib/src/db/defs/tile.dart +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:isar/isar.dart'; -import 'package:meta/meta.dart'; - -import '../tools.dart'; - -part 'tile.g.dart'; - -@internal -@Collection(accessor: 'tiles') -class DbTile { - Id get id => DatabaseTools.hash(url); - - final String url; - final List bytes; - - @Index() - final DateTime lastModified; - - DbTile({ - required this.url, - required this.bytes, - }) : lastModified = DateTime.now(); -} diff --git a/lib/src/db/defs/tile.g.dart b/lib/src/db/defs/tile.g.dart deleted file mode 100644 index f50c76b8..00000000 --- a/lib/src/db/defs/tile.g.dart +++ /dev/null @@ -1,785 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'tile.dart'; - -// ************************************************************************** -// IsarCollectionGenerator -// ************************************************************************** - -// coverage:ignore-file -// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types - -extension GetDbTileCollection on Isar { - IsarCollection get tiles => this.collection(); -} - -const DbTileSchema = CollectionSchema( - name: r'DbTile', - id: -5030120948284417748, - properties: { - r'bytes': PropertySchema( - id: 0, - name: r'bytes', - type: IsarType.byteList, - ), - r'lastModified': PropertySchema( - id: 1, - name: r'lastModified', - type: IsarType.dateTime, - ), - r'url': PropertySchema( - id: 2, - name: r'url', - type: IsarType.string, - ) - }, - estimateSize: _dbTileEstimateSize, - serialize: _dbTileSerialize, - deserialize: _dbTileDeserialize, - deserializeProp: _dbTileDeserializeProp, - idName: r'id', - indexes: { - r'lastModified': IndexSchema( - id: 5953778071269117195, - name: r'lastModified', - unique: false, - replace: false, - properties: [ - IndexPropertySchema( - name: r'lastModified', - type: IndexType.value, - caseSensitive: false, - ) - ], - ) - }, - links: {}, - embeddedSchemas: {}, - getId: _dbTileGetId, - getLinks: _dbTileGetLinks, - attach: _dbTileAttach, - version: '3.1.0+1', -); - -int _dbTileEstimateSize( - DbTile object, - List offsets, - Map> allOffsets, -) { - var bytesCount = offsets.last; - bytesCount += 3 + object.bytes.length; - bytesCount += 3 + object.url.length * 3; - return bytesCount; -} - -void _dbTileSerialize( - DbTile object, - IsarWriter writer, - List offsets, - Map> allOffsets, -) { - writer.writeByteList(offsets[0], object.bytes); - writer.writeDateTime(offsets[1], object.lastModified); - writer.writeString(offsets[2], object.url); -} - -DbTile _dbTileDeserialize( - Id id, - IsarReader reader, - List offsets, - Map> allOffsets, -) { - final object = DbTile( - bytes: reader.readByteList(offsets[0]) ?? [], - url: reader.readString(offsets[2]), - ); - return object; -} - -P _dbTileDeserializeProp

( - IsarReader reader, - int propertyId, - int offset, - Map> allOffsets, -) { - switch (propertyId) { - case 0: - return (reader.readByteList(offset) ?? []) as P; - case 1: - return (reader.readDateTime(offset)) as P; - case 2: - return (reader.readString(offset)) as P; - default: - throw IsarError('Unknown property with id $propertyId'); - } -} - -Id _dbTileGetId(DbTile object) { - return object.id; -} - -List> _dbTileGetLinks(DbTile object) { - return []; -} - -void _dbTileAttach(IsarCollection col, Id id, DbTile object) {} - -extension DbTileQueryWhereSort on QueryBuilder { - QueryBuilder anyId() { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(const IdWhereClause.any()); - }); - } - - QueryBuilder anyLastModified() { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause( - const IndexWhereClause.any(indexName: r'lastModified'), - ); - }); - } -} - -extension DbTileQueryWhere on QueryBuilder { - QueryBuilder idEqualTo(Id id) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IdWhereClause.between( - lower: id, - upper: id, - )); - }); - } - - QueryBuilder idNotEqualTo(Id id) { - return QueryBuilder.apply(this, (query) { - if (query.whereSort == Sort.asc) { - return query - .addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: false), - ) - .addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: false), - ); - } else { - return query - .addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: false), - ) - .addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: false), - ); - } - }); - } - - QueryBuilder idGreaterThan(Id id, - {bool include = false}) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause( - IdWhereClause.greaterThan(lower: id, includeLower: include), - ); - }); - } - - QueryBuilder idLessThan(Id id, - {bool include = false}) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause( - IdWhereClause.lessThan(upper: id, includeUpper: include), - ); - }); - } - - QueryBuilder idBetween( - Id lowerId, - Id upperId, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IdWhereClause.between( - lower: lowerId, - includeLower: includeLower, - upper: upperId, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder lastModifiedEqualTo( - DateTime lastModified) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IndexWhereClause.equalTo( - indexName: r'lastModified', - value: [lastModified], - )); - }); - } - - QueryBuilder lastModifiedNotEqualTo( - DateTime lastModified) { - return QueryBuilder.apply(this, (query) { - if (query.whereSort == Sort.asc) { - return query - .addWhereClause(IndexWhereClause.between( - indexName: r'lastModified', - lower: [], - upper: [lastModified], - includeUpper: false, - )) - .addWhereClause(IndexWhereClause.between( - indexName: r'lastModified', - lower: [lastModified], - includeLower: false, - upper: [], - )); - } else { - return query - .addWhereClause(IndexWhereClause.between( - indexName: r'lastModified', - lower: [lastModified], - includeLower: false, - upper: [], - )) - .addWhereClause(IndexWhereClause.between( - indexName: r'lastModified', - lower: [], - upper: [lastModified], - includeUpper: false, - )); - } - }); - } - - QueryBuilder lastModifiedGreaterThan( - DateTime lastModified, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IndexWhereClause.between( - indexName: r'lastModified', - lower: [lastModified], - includeLower: include, - upper: [], - )); - }); - } - - QueryBuilder lastModifiedLessThan( - DateTime lastModified, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IndexWhereClause.between( - indexName: r'lastModified', - lower: [], - upper: [lastModified], - includeUpper: include, - )); - }); - } - - QueryBuilder lastModifiedBetween( - DateTime lowerLastModified, - DateTime upperLastModified, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addWhereClause(IndexWhereClause.between( - indexName: r'lastModified', - lower: [lowerLastModified], - includeLower: includeLower, - upper: [upperLastModified], - includeUpper: includeUpper, - )); - }); - } -} - -extension DbTileQueryFilter on QueryBuilder { - QueryBuilder bytesElementEqualTo( - int value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'bytes', - value: value, - )); - }); - } - - QueryBuilder bytesElementGreaterThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'bytes', - value: value, - )); - }); - } - - QueryBuilder bytesElementLessThan( - int value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'bytes', - value: value, - )); - }); - } - - QueryBuilder bytesElementBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'bytes', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder bytesLengthEqualTo( - int length) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'bytes', - length, - true, - length, - true, - ); - }); - } - - QueryBuilder bytesIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'bytes', - 0, - true, - 0, - true, - ); - }); - } - - QueryBuilder bytesIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'bytes', - 0, - false, - 999999, - true, - ); - }); - } - - QueryBuilder bytesLengthLessThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'bytes', - 0, - true, - length, - include, - ); - }); - } - - QueryBuilder bytesLengthGreaterThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'bytes', - length, - include, - 999999, - true, - ); - }); - } - - QueryBuilder bytesLengthBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'bytes', - lower, - includeLower, - upper, - includeUpper, - ); - }); - } - - QueryBuilder idEqualTo(Id value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'id', - value: value, - )); - }); - } - - QueryBuilder idGreaterThan( - Id value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'id', - value: value, - )); - }); - } - - QueryBuilder idLessThan( - Id value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'id', - value: value, - )); - }); - } - - QueryBuilder idBetween( - Id lower, - Id upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'id', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder lastModifiedEqualTo( - DateTime value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'lastModified', - value: value, - )); - }); - } - - QueryBuilder lastModifiedGreaterThan( - DateTime value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'lastModified', - value: value, - )); - }); - } - - QueryBuilder lastModifiedLessThan( - DateTime value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'lastModified', - value: value, - )); - }); - } - - QueryBuilder lastModifiedBetween( - DateTime lower, - DateTime upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'lastModified', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - - QueryBuilder urlEqualTo( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'url', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder urlGreaterThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'url', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder urlLessThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'url', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder urlBetween( - String lower, - String upper, { - bool includeLower = true, - bool includeUpper = true, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'url', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder urlStartsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.startsWith( - property: r'url', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder urlEndsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.endsWith( - property: r'url', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder urlContains(String value, - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.contains( - property: r'url', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder urlMatches(String pattern, - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.matches( - property: r'url', - wildcard: pattern, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder urlIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'url', - value: '', - )); - }); - } - - QueryBuilder urlIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - property: r'url', - value: '', - )); - }); - } -} - -extension DbTileQueryObject on QueryBuilder {} - -extension DbTileQueryLinks on QueryBuilder {} - -extension DbTileQuerySortBy on QueryBuilder { - QueryBuilder sortByLastModified() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'lastModified', Sort.asc); - }); - } - - QueryBuilder sortByLastModifiedDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'lastModified', Sort.desc); - }); - } - - QueryBuilder sortByUrl() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'url', Sort.asc); - }); - } - - QueryBuilder sortByUrlDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'url', Sort.desc); - }); - } -} - -extension DbTileQuerySortThenBy on QueryBuilder { - QueryBuilder thenById() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'id', Sort.asc); - }); - } - - QueryBuilder thenByIdDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'id', Sort.desc); - }); - } - - QueryBuilder thenByLastModified() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'lastModified', Sort.asc); - }); - } - - QueryBuilder thenByLastModifiedDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'lastModified', Sort.desc); - }); - } - - QueryBuilder thenByUrl() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'url', Sort.asc); - }); - } - - QueryBuilder thenByUrlDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'url', Sort.desc); - }); - } -} - -extension DbTileQueryWhereDistinct on QueryBuilder { - QueryBuilder distinctByBytes() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'bytes'); - }); - } - - QueryBuilder distinctByLastModified() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'lastModified'); - }); - } - - QueryBuilder distinctByUrl( - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'url', caseSensitive: caseSensitive); - }); - } -} - -extension DbTileQueryProperty on QueryBuilder { - QueryBuilder idProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'id'); - }); - } - - QueryBuilder, QQueryOperations> bytesProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'bytes'); - }); - } - - QueryBuilder lastModifiedProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'lastModified'); - }); - } - - QueryBuilder urlProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'url'); - }); - } -} diff --git a/lib/src/db/registry.dart b/lib/src/db/registry.dart deleted file mode 100644 index 707631d2..00000000 --- a/lib/src/db/registry.dart +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'dart:io'; - -import 'package:isar/isar.dart'; -import 'package:meta/meta.dart'; -import 'package:path/path.dart' as p; -import 'package:stream_transform/stream_transform.dart'; - -import '../../flutter_map_tile_caching.dart'; -import '../misc/exts.dart'; -import 'defs/metadata.dart'; -import 'defs/recovery.dart'; -import 'defs/store_descriptor.dart'; -import 'defs/tile.dart'; -import 'tools.dart'; - -/// Manages the stores available -/// -/// It is very important for the [_storeDatabases] state to remain in sync with -/// the actual state of the [directory], otherwise unexpected behaviour may -/// occur. -@internal -class FMTCRegistry { - const FMTCRegistry._({ - required this.directory, - required this.recoveryDatabase, - required Map storeDatabases, - }) : _storeDatabases = storeDatabases; - - static late FMTCRegistry instance; - - final Directory directory; - final Isar recoveryDatabase; - final Map _storeDatabases; - - static Future initialise({ - required Directory directory, - required int databaseMaxSize, - required CompactCondition? databaseCompactCondition, - required void Function(FMTCInitialisationException error)? errorHandler, - required IOSink? initialisationSafetyWriteSink, - required List? safeModeSuccessfulIDs, - required bool debugMode, - }) async { - // Set up initialisation safety features - bool hasLocatedCorruption = false; - - Future deleteDatabaseAndRelatedFiles(String base) async { - try { - await Future.wait( - await directory - .list() - .where((e) => e is File && p.basename(e.path).startsWith(base)) - .map((e) async { - if (await e.exists()) return e.delete(); - }).toList(), - ); - // ignore: empty_catches - } catch (e) {} - } - - Future registerSafeDatabase(String base) async { - initialisationSafetyWriteSink?.writeln(base); - await initialisationSafetyWriteSink?.flush(); - } - - // Prepare open database method - Future?> openIsar(String id, File file) async { - try { - return MapEntry( - int.parse(id), - await Isar.open( - [DbStoreDescriptorSchema, DbTileSchema, DbMetadataSchema], - name: id, - directory: directory.absolute.path, - maxSizeMiB: databaseMaxSize, - compactOnLaunch: databaseCompactCondition, - inspector: debugMode, - ), - ); - } catch (err) { - await deleteDatabaseAndRelatedFiles(p.basename(file.path)); - errorHandler?.call( - FMTCInitialisationException( - 'Failed to initialise a store because Isar failed to open the database.', - FMTCInitialisationExceptionType.isarFailure, - originalError: err, - ), - ); - return null; - } - } - - // Open recovery database - if (!(safeModeSuccessfulIDs?.contains('.recovery') ?? true) && - await (directory >>> '.recovery.isar').exists()) { - await deleteDatabaseAndRelatedFiles('.recovery'); - hasLocatedCorruption = true; - errorHandler?.call( - FMTCInitialisationException( - 'Failed to open a database because it was not listed as safe/stable on last initialisation.', - FMTCInitialisationExceptionType.corruptedDatabase, - storeName: '.recovery', - ), - ); - } - final recoveryDatabase = await Isar.open( - [ - DbRecoverableRegionSchema, - if (debugMode) ...[ - DbStoreDescriptorSchema, - DbTileSchema, - DbMetadataSchema, - ], - ], - name: '.recovery', - directory: directory.absolute.path, - maxSizeMiB: databaseMaxSize, - compactOnLaunch: databaseCompactCondition, - inspector: debugMode, - ); - await registerSafeDatabase('.recovery'); - - // Open store databases - return instance = FMTCRegistry._( - directory: directory, - recoveryDatabase: recoveryDatabase, - storeDatabases: Map.fromEntries( - await directory - .list() - .where( - (e) => - e is File && - !p.basename(e.path).startsWith('.') && - p.extension(e.path) == '.isar', - ) - .asyncMap((file) async { - final id = p.basenameWithoutExtension(file.path); - final path = p.basename(file.path); - - // Check whether the database is safe - if (!hasLocatedCorruption && - safeModeSuccessfulIDs != null && - !safeModeSuccessfulIDs.contains(id)) { - await deleteDatabaseAndRelatedFiles(path); - hasLocatedCorruption = true; - errorHandler?.call( - FMTCInitialisationException( - 'Failed to open a database because it was not listed as safe/stable on last initialisation.', - FMTCInitialisationExceptionType.corruptedDatabase, - ), - ); - return null; - } - - // Open the database - MapEntry? entry = await openIsar(id, file as File); - if (entry == null) return null; - - // Correct the database ID (filename) if the store name doesn't - // match - final storeName = (await entry.value.descriptor).name; - final realId = DatabaseTools.hash(storeName); - if (realId != int.parse(id)) { - await entry.value.close(); - file = await file - .rename(Directory(p.dirname(file.path)) > '$realId.isar'); - entry = await openIsar(realId.toString(), file); - await deleteDatabaseAndRelatedFiles(path); - } - - // Register the database as safe and add it to the registry - await registerSafeDatabase(id); - return entry; - }) - .whereNotNull() - .toList(), - ), - ); - } - - Future uninitialise({bool delete = false}) async { - await Future.wait([ - ...FMTC.instance.rootDirectory.stats.storesAvailable - .map((s) => s.manage.delete()), - recoveryDatabase.close(deleteFromDisk: delete), - ]); - } - - Isar call(String storeName) { - final id = DatabaseTools.hash(storeName); - final isRegistered = _storeDatabases.containsKey(id); - if (!(isRegistered && _storeDatabases[id]!.isOpen)) { - throw FMTCStoreNotReady( - storeName: storeName, - registered: isRegistered, - ); - } - return _storeDatabases[id]!; - } - - Isar register(int id, Isar db) => _storeDatabases[id] = db; - Isar? unregister(int id) => _storeDatabases.remove(id); - Map get storeDatabases => _storeDatabases; -} diff --git a/lib/src/db/tools.dart b/lib/src/db/tools.dart deleted file mode 100644 index 695e6643..00000000 --- a/lib/src/db/tools.dart +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:isar/isar.dart'; -import 'package:meta/meta.dart'; - -import '../../flutter_map_tile_caching.dart'; -import 'defs/store_descriptor.dart'; - -@internal -class DatabaseTools { - static int hash(String string) { - final str = string.trim(); - - // ignore: avoid_js_rounded_ints - int hash = 0xcbf29ce484222325; - int i = 0; - - while (i < str.length) { - final codeUnit = str.codeUnitAt(i++); - hash ^= codeUnit >> 8; - hash *= 0x100000001b3; - hash ^= codeUnit & 0xFF; - hash *= 0x100000001b3; - } - - return hash; - } -} - -extension IsarExts on Isar { - Future get descriptor async { - final descriptor = await storeDescriptor.get(0); - if (descriptor == null) { - throw FMTCDamagedStoreException( - 'Failed to perform an operation on a store due to the core descriptor being missing.', - FMTCDamagedStoreExceptionType.missingStoreDescriptor, - ); - } - return descriptor; - } - - DbStoreDescriptor get descriptorSync { - final descriptor = storeDescriptor.getSync(0); - if (descriptor == null) { - throw FMTCDamagedStoreException( - 'Failed to perform an operation on a store due to the core descriptor being missing.', - FMTCDamagedStoreExceptionType.missingStoreDescriptor, - ); - } - return descriptor; - } -} diff --git a/lib/src/errors/browsing.dart b/lib/src/errors/browsing.dart deleted file mode 100644 index ace1fcd3..00000000 --- a/lib/src/errors/browsing.dart +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:meta/meta.dart'; - -import '../../flutter_map_tile_caching.dart'; -import '../providers/image_provider.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]. -/// -/// Always thrown from within [FMTCImageProvider] generated from -/// [FMTCTileProvider]. The [message] further indicates the reason, and will -/// depend on the current caching behaviour. The [type] represents the same -/// message in a way that is easy to parse/handle. -class FMTCBrowsingError implements Exception { - /// Friendly message - final String message; - - /// Programmatic error descriptor - final FMTCBrowsingErrorType type; - - /// 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]. - /// - /// Always thrown from within [FMTCImageProvider] generated from - /// [FMTCTileProvider]. The [message] further indicates the reason, and will - /// depend on the current caching behaviour. The [type] represents the same - /// message in a way that is easy to parse/handle. - @internal - FMTCBrowsingError(this.message, this.type); - - @override - String toString() => 'FMTCBrowsingError: $message'; -} - -/// Pragmatic error descriptor for a [FMTCBrowsingError.message] -/// -/// See documentation on that object for more information. -enum FMTCBrowsingErrorType { - /// Paired with friendly message: - /// "Failed to load the tile from the cache because it was missing." - missingInCacheOnlyMode, - - /// Paired with friendly message: - /// "Failed to load the tile from the cache or the network because it was - /// missing from the cache and a connection to the server could not be - /// established." - noConnectionDuringFetch, - - /// Paired with friendly message: - /// "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 of <$>." - negativeFetchResponse, -} diff --git a/lib/src/errors/damaged_store.dart b/lib/src/errors/damaged_store.dart deleted file mode 100644 index 1fc4363b..00000000 --- a/lib/src/errors/damaged_store.dart +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:meta/meta.dart'; - -/// An [Exception] indicating that an operation was attempted on a damaged store -/// -/// Can be thrown for multiple reasons. See [type] and -/// [FMTCDamagedStoreExceptionType] for more information. -class FMTCDamagedStoreException implements Exception { - /// Friendly message - final String message; - - /// Programmatic error descriptor - final FMTCDamagedStoreExceptionType type; - - /// An [Exception] indicating that an operation was attempted on a damaged store - /// - /// Can be thrown for multiple reasons. See [type] and - /// [FMTCDamagedStoreExceptionType] for more information. - @internal - FMTCDamagedStoreException(this.message, this.type); - - @override - String toString() => 'FMTCDamagedStoreException: $message'; -} - -/// Pragmatic error descriptor for a [FMTCDamagedStoreException.message] -/// -/// See documentation on that object for more information. -enum FMTCDamagedStoreExceptionType { - /// Paired with friendly message: - /// "Failed to perform an operation on a store due to the core descriptor - /// being missing." - missingStoreDescriptor, - - /// Paired with friendly message: - /// "Something went wrong." - unknown, -} diff --git a/lib/src/errors/initialisation.dart b/lib/src/errors/initialisation.dart deleted file mode 100644 index 572ed2d5..00000000 --- a/lib/src/errors/initialisation.dart +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:meta/meta.dart'; - -/// An [Exception] raised when FMTC failed to initialise a store -/// -/// Can be thrown for multiple reasons. See [type] and -/// [FMTCInitialisationExceptionType] for more information. -/// -/// A failed store initialisation will always result in the store being deleted -/// ASAP, regardless of its contents. -class FMTCInitialisationException implements Exception { - /// Friendly message - final String message; - - /// Programmatic error descriptor - final FMTCInitialisationExceptionType type; - - /// Name of the store that could not be initialised, if known - final String? storeName; - - /// Original error object (error is not directly thrown by FMTC), if applicable - final Object? originalError; - - /// An [Exception] raised when FMTC failed to initialise a store - /// - /// Can be thrown for multiple reasons. See [type] and - /// [FMTCInitialisationExceptionType] for more information. - /// - /// A failed store initialisation will always result in the store being deleted - /// ASAP, regardless of its contents. - @internal - FMTCInitialisationException( - this.message, - this.type, { - this.storeName, - this.originalError, - }); - - @override - String toString() => 'FMTCInitialisationException: $message'; -} - -/// Pragmatic error descriptor for a [FMTCInitialisationException.message] -/// -/// See documentation on that object for more information. -enum FMTCInitialisationExceptionType { - /// Paired with friendly message: - /// "Failed to initialise a store because it was not listed as safe/stable on last - /// initialisation." - /// - /// This signifies that the application has previously fatally crashed during - /// initialisation, but that the initialisation safety system has now removed - /// the store database. - corruptedDatabase, - - /// Paired with friendly message: - /// "Failed to initialise a store because Isar failed to open the database." - /// - /// This usually means the store database was valid, but Isar was not - /// configured correctly or encountered another issue. Unlike - /// [corruptedDatabase], this does not cause an app crash. Consult the - /// [FMTCInitialisationException.originalError] for more information. - isarFailure, -} diff --git a/lib/src/errors/store_not_ready.dart b/lib/src/errors/store_not_ready.dart deleted file mode 100644 index 2d374d38..00000000 --- a/lib/src/errors/store_not_ready.dart +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:meta/meta.dart'; - -/// An [Error] indicating that a store did not exist when it was expected to -/// -/// Commonly thrown by statistic operations, but can be thrown from multiple -/// other places. -class FMTCStoreNotReady extends Error { - /// The store name that the method tried to access - final String storeName; - - /// A human readable description of the error, and steps that may be taken to - /// avoid this error being thrown again - final String message; - - /// Whether this store was registered internally. - /// - /// Represents a serious internal FMTC error if `true`, as represented by - /// [message]. - final bool registered; - - /// An [Error] indicating that a store did not exist when it was expected to - /// - /// Commonly thrown by statistic operations, but can be thrown from multiple - /// other places. - @internal - FMTCStoreNotReady({ - required this.storeName, - required this.registered, - }) : message = registered - ? "The store ('$storeName') was registered, but the underlying database was not open, at this time. This is an erroneous state in FMTC: if this error appears in your application, please open an issue on GitHub immediately." - : "The store ('$storeName') does not exist at this time, and is not ready. Ensure that your application does not use the method that triggered this error unless it is sure that the store will exist at this point."; - - /// Similar to [message], but suitable for console output in an unknown context - @override - String toString() => 'FMTCStoreNotReady: $message'; -} diff --git a/lib/src/fmtc.dart b/lib/src/fmtc.dart deleted file mode 100644 index b381515e..00000000 --- a/lib/src/fmtc.dart +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of flutter_map_tile_caching; - -/// Direct alias of [FlutterMapTileCaching] for easier development -/// -/// Prefer use of full 'FlutterMapTileCaching' when initialising to ensure -/// readability and understanding in other code. -typedef FMTC = FlutterMapTileCaching; - -/// Main singleton access point for 'flutter_map_tile_caching' -/// -/// You must construct using [FlutterMapTileCaching.initialise] before using -/// [FlutterMapTileCaching.instance], otherwise a [StateError] will be thrown. -/// Note that the singleton can be re-initialised/changed by calling the -/// aforementioned constructor again. -/// -/// [FMTC] is an alias for this object. -class FlutterMapTileCaching { - /// The directory which contains all databases required to use FMTC - final RootDirectory rootDirectory; - - /// Custom global 'flutter_map_tile_caching' settings - /// - /// See [FMTCSettings]' properties for more information - final FMTCSettings settings; - - /// Whether FMTC should perform extra reporting and console logging - /// - /// Depends on [_debugMode] (set via [initialise]) and [kDebugMode]. - bool get debugMode => _debugMode && kDebugMode; - final bool _debugMode; - - /// Internal constructor, to be used by [initialise] - const FlutterMapTileCaching._({ - required this.rootDirectory, - required this.settings, - required bool debugMode, - }) : _debugMode = debugMode; - - /// Initialise and prepare FMTC, by creating all neccessary directories/files - /// and configuring the [FlutterMapTileCaching] singleton - /// - /// Prefer to leave [rootDirectory] as `null`, which will use - /// `getApplicationDocumentsDirectory()`. Alternatively, pass a custom - /// directory - it is recommended to not use a cache directory, as the OS can - /// clear these without notice at any time. - /// - /// You must construct using this before using [FlutterMapTileCaching.instance], - /// otherwise a [StateError] will be thrown. - /// - /// The initialisation safety system ensures that a corrupted database cannot - /// prevent the app from launching. However, one fatal crash is necessary for - /// each corrupted database, as this allows each one to be individually located - /// and deleted, due to limitations in dependencies. Note that any triggering - /// of the safety system will also reset the recovery database, meaning - /// recovery information will be lost. - /// - /// [errorHandler] must not (re)throw an error, as this interferes with the - /// initialisation safety system, and may result in unnecessary data loss. - /// - /// Setting [disableInitialisationSafety] `true` will disable the - /// initialisation safety system, and is not recommended, as this may leave the - /// application unable to launch if any database becomes corrupted. - /// - /// Setting [debugMode] `true` can be useful to diagnose issues, either within - /// your application or FMTC itself. It enables the Isar inspector and causes - /// extra console logging in important areas. Prefer to leave disabled to - /// prevent console pollution and to maximise performance. Whether FMTC chooses - /// to listen to this value is also dependent on [kDebugMode] - see - /// [FlutterMapTileCaching.debugMode] for more information. - /// _Extra logging is currently limited._ - /// - /// This returns a configured [FlutterMapTileCaching], the same object as - /// [FlutterMapTileCaching.instance]. Note that [FMTC] is an alias for this - /// object. - static Future initialise({ - String? rootDirectory, - FMTCSettings? settings, - void Function(FMTCInitialisationException error)? errorHandler, - bool disableInitialisationSafety = false, - bool debugMode = false, - }) async { - final directory = await ((rootDirectory == null - ? await getApplicationDocumentsDirectory() - : Directory(rootDirectory)) >> - 'fmtc') - .create(recursive: true); - - settings ??= FMTCSettings(); - - if (!disableInitialisationSafety) { - final initialisationSafetyFile = - directory >>> '.initialisationSafety.tmp'; - final needsRescue = await initialisationSafetyFile.exists(); - - await initialisationSafetyFile.create(); - final writeSink = - initialisationSafetyFile.openWrite(mode: FileMode.writeOnlyAppend); - - await FMTCRegistry.initialise( - directory: directory, - databaseMaxSize: settings.databaseMaxSize, - databaseCompactCondition: settings.databaseCompactCondition, - errorHandler: errorHandler, - initialisationSafetyWriteSink: writeSink, - safeModeSuccessfulIDs: - needsRescue ? await initialisationSafetyFile.readAsLines() : null, - debugMode: debugMode && kDebugMode, - ); - - await writeSink.close(); - await initialisationSafetyFile.delete(); - } else { - await FMTCRegistry.initialise( - directory: directory, - databaseMaxSize: settings.databaseMaxSize, - databaseCompactCondition: settings.databaseCompactCondition, - errorHandler: errorHandler, - initialisationSafetyWriteSink: null, - safeModeSuccessfulIDs: null, - debugMode: debugMode && kDebugMode, - ); - } - - return _instance = FMTC._( - rootDirectory: RootDirectory._(directory), - settings: settings, - debugMode: debugMode, - ); - } - - /// The singleton instance of [FlutterMapTileCaching] at call time - /// - /// Must not be read or written directly, except in - /// [FlutterMapTileCaching.instance] and [FlutterMapTileCaching.initialise] - /// respectively. - static FlutterMapTileCaching? _instance; - - /// Get the configured instance of [FlutterMapTileCaching], after - /// [FlutterMapTileCaching.initialise] has been called, for further actions - static FlutterMapTileCaching get instance { - if (_instance == null) { - throw StateError( - 'Use `FlutterMapTileCaching.initialise()` before getting `FlutterMapTileCaching.instance`.', - ); - } - return _instance!; - } - - /// Get a [StoreDirectory] by name, without creating it automatically - /// - /// Use `.manage.create()` to create it asynchronously. Alternatively, use - /// `[]` to get a store by name and automatically create it synchronously. - StoreDirectory call(String storeName) => StoreDirectory._( - storeName, - autoCreate: false, - ); - - /// Get a [StoreDirectory] by name, and create it synchronously automatically - /// - /// Prefer [call]/`()` wherever possible, as this method blocks the thread. - /// Note that that method does not automatically create the store. - StoreDirectory operator [](String storeName) => StoreDirectory._( - storeName, - autoCreate: true, - ); -} diff --git a/lib/src/misc/deprecations.dart b/lib/src/misc/deprecations.dart new file mode 100644 index 00000000..30eccac3 --- /dev/null +++ b/lib/src/misc/deprecations.dart @@ -0,0 +1,199 @@ +// 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/exts.dart b/lib/src/misc/exts.dart deleted file mode 100644 index d9484144..00000000 --- a/lib/src/misc/exts.dart +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'dart:io'; - -import 'package:meta/meta.dart'; -import 'package:path/path.dart' as p; - -@internal -extension DirectoryExtensions on Directory { - String operator >(String sub) => p.join( - absolute.path, - sub, - ); - - Directory operator >>(String sub) => Directory( - p.join( - absolute.path, - sub, - ), - ); - - File operator >>>(String name) => File( - p.join( - absolute.path, - name, - ), - ); -} diff --git a/lib/src/misc/int_extremes.dart b/lib/src/misc/int_extremes.dart new file mode 100644 index 00000000..c7471a16 --- /dev/null +++ b/lib/src/misc/int_extremes.dart @@ -0,0 +1,12 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'package:meta/meta.dart'; + +/// Largest fully representable integer in Dart +@internal +const largestInt = 9223372036854775807; + +/// Smallest fully representable integer in Dart +@internal +const smallestInt = -9223372036854775808; diff --git a/lib/src/misc/obscure_query_params.dart b/lib/src/misc/obscure_query_params.dart new file mode 100644 index 00000000..59f35aad --- /dev/null +++ b/lib/src/misc/obscure_query_params.dart @@ -0,0 +1,21 @@ +// 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/misc/store_db_impl.dart b/lib/src/misc/store_db_impl.dart deleted file mode 100644 index 747d710a..00000000 --- a/lib/src/misc/store_db_impl.dart +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of flutter_map_tile_caching; - -abstract class _StoreDb { - const _StoreDb(this._store); - - final StoreDirectory _store; - Isar get _db => FMTCRegistry.instance(_store.storeName); -} diff --git a/lib/src/misc/typedefs.dart b/lib/src/misc/typedefs.dart deleted file mode 100644 index f47653a0..00000000 --- a/lib/src/misc/typedefs.dart +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -import 'package:isar/isar.dart'; - -import '../../flutter_map_tile_caching.dart'; - -/// See [FMTCSettings.databaseCompactCondition] and [CompactCondition]'s -/// documentation for more information -typedef DatabaseCompactCondition = CompactCondition; diff --git a/lib/src/providers/browsing_errors.dart b/lib/src/providers/browsing_errors.dart new file mode 100644 index 00000000..cb0af4f0 --- /dev/null +++ b/lib/src/providers/browsing_errors.dart @@ -0,0 +1,178 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +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'; + +/// 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]. +/// +/// Use [type] to establish the condition that threw this exception, and +/// [message] for a user-friendly English description of this exception. Also +/// see the other properties for more information. +class FMTCBrowsingError implements Exception { + /// 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]. + /// + /// Use [type] to establish the condition that threw this exception, and + /// [message] for a user-friendly English description of this exception. Also + /// see the other properties for more information. + @internal + FMTCBrowsingError({ + required this.type, + required this.networkUrl, + required this.matcherUrl, + this.request, + this.response, + this.originalError, + }) : message = '${type.explanation} ${type.resolution}'; + + /// Defines the condition that threw this exception + /// + /// See [message] for a user friendly description of this value. + final FMTCBrowsingErrorType type; + + /// A user-friendly English description of the [type] of this exception, + /// suitable for UI display, also with some hints at a potential resolution + /// or debugging step. + /// + /// Need just the description, or just the resolution step? See + /// [FMTCBrowsingErrorType.explanation] & [FMTCBrowsingErrorType.resolution]. + final String message; + + /// Generated network URL at which the tile was requested from + 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; + + /// If available, the HTTP response streamed from the server + /// + /// Will be available if [type] is + /// [FMTCBrowsingErrorType.negativeFetchResponse] or + /// [FMTCBrowsingErrorType.invalidImageData]. + final StreamedResponse? 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]. + final Object? originalError; + + @override + String toString() => 'FMTCBrowsingError (${type.name}): $message'; +} + +/// Defines the type of issue that a [FMTCBrowsingError] is reporting +/// +/// See [explanation] and [resolution] for more information about each type. +/// [FMTCBrowsingError.message] is formed from the concatenation of these two +/// properties. +enum FMTCBrowsingErrorType { + /// Failed to load the tile from the cache because it was missing + /// + /// Ensure that tiles are cached before using [CacheBehavior.cacheOnly]. + missingInCacheOnlyMode( + 'Failed to load the tile from the cache because it was missing.', + 'Ensure that tiles are cached before using `CacheBehavior.cacheOnly`.', + ), + + /// Failed to load the tile from the cache or the network because it was + /// missing from the cache and a connection to the server could not be + /// established + /// + /// Check your Internet connection. + noConnectionDuringFetch( + 'Failed to load the tile from the cache or the network because it was ' + 'missing from the cache and a connection to the server could not be ' + 'established.', + 'Check your Internet connection.', + ), + + /// 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 ' + 'the server.', + 'Try specifying a normal HTTP/1.1 `IOClient` when using `getTileProvider`. ' + 'Check that the `TileLayer.urlTemplate` is correct, that any necessary ' + 'authorization data is correctly included, and that the server serves ' + 'the viewed region.', + ), + + /// 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 + /// + /// 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.', + 'Check that the `TileLayer.urlTemplate` is correct, that any necessary ' + 'authorization data is correctly included, and that the server serves ' + 'the viewed region.', + ), + + /// Failed to load the tile from the network because it responded with an HTTP + /// code of 200 OK but an invalid image data + /// + /// Your server may be misconfigured and returning an error message or blank + /// response under 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. + invalidImageData( + 'Failed to load the tile from the network because it responded with an ' + 'HTTP code of 200 OK but an invalid image data.', + 'Your server may be misconfigured and returning an error message or blank ' + 'response under 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.', + ); + + /// Defines the type of issue that a [FMTCBrowsingError] is reporting + /// + /// See [explanation] and [resolution] for more information about each type. + /// [FMTCBrowsingError.message] is formed from the concatenation of these two + /// properties. + @internal + const FMTCBrowsingErrorType(this.explanation, this.resolution); + + /// A user-friendly English description of this exception, suitable for UI + /// display + final String explanation; + + /// Guidance (in user-friendly English) for how this exception might be + /// resolved, or at least a first debugging step + final String resolution; +} diff --git a/lib/src/providers/image_provider.dart b/lib/src/providers/image_provider.dart index ca56c691..fac44747 100644 --- a/lib/src/providers/image_provider.dart +++ b/lib/src/providers/image_provider.dart @@ -2,24 +2,32 @@ // 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/plugin_api.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:http/http.dart'; -import 'package:isar/isar.dart'; -import 'package:queue/queue.dart'; import '../../flutter_map_tile_caching.dart'; -import '../db/defs/metadata.dart'; -import '../db/defs/store_descriptor.dart'; -import '../db/defs/tile.dart'; -import '../db/registry.dart'; -import '../db/tools.dart'; +import '../backend/export_internal.dart'; +import '../misc/obscure_query_params.dart'; -/// A specialised [ImageProvider] dedicated to 'flutter_map_tile_caching' +/// 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; @@ -29,186 +37,241 @@ class FMTCImageProvider extends ImageProvider { /// The coordinates of the tile to be fetched final TileCoordinates coords; - /// Configured root directory - final String directory; - - /// The database to write tiles to - final Isar db; + /// 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; - static final _removeOldestQueue = Queue(timeout: const Duration(seconds: 1)); - static final _cacheHitsQueue = Queue(); - static final _cacheMissesQueue = Queue(); - - /// Create a specialised [ImageProvider] dedicated to 'flutter_map_tile_caching' - FMTCImageProvider({ - required this.provider, - required this.options, - required this.coords, - required this.directory, - }) : db = FMTCRegistry.instance(provider.storeDirectory.storeName); + /// 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 loadBuffer( + ImageStreamCompleter loadImage( FMTCImageProvider key, - DecoderBufferCallback decode, + ImageDecoderCallback decode, ) { - // ignore: close_sinks - final StreamController chunkEvents = - StreamController(); - + final chunkEvents = StreamController(); return MultiFrameImageStreamCompleter( - codec: _loadAsync(key: key, decode: decode, chunkEvents: chunkEvents), + codec: _loadAsync(key, chunkEvents, decode), chunkEvents: chunkEvents.stream, scale: 1, debugLabel: coords.toString(), - informationCollector: () => [DiagnosticsProperty('Coordinates', coords)], + informationCollector: () => [ + DiagnosticsProperty('Store name', provider.storeName), + DiagnosticsProperty('Tile coordinates', coords), + DiagnosticsProperty('Current provider', key), + ], ); } - Future _loadAsync({ - required FMTCImageProvider key, - required DecoderBufferCallback decode, - required StreamController chunkEvents, - }) async { - Future cacheHitMiss({ - required bool hit, - }) => - (hit ? _cacheHitsQueue : _cacheMissesQueue).add(() async { - if (db.isOpen) { - await db.writeTxn(() async { - final store = db.isOpen ? await db.descriptor : null; - if (store == null) return; - if (hit) store.hits += 1; - if (!hit) store.misses += 1; - await db.storeDescriptor.put(store); - }); - } - }); - - Future finish({ - List? bytes, - String? throwError, - FMTCBrowsingErrorType? throwErrorType, - bool? cacheHit, - }) async { + Future _loadAsync( + FMTCImageProvider key, + StreamController chunkEvents, + ImageDecoderCallback decode, + ) async { + Future finishWithError(FMTCBrowsingError err) async { scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); unawaited(chunkEvents.close()); + finishedLoadingBytes(); - if (cacheHit != null) unawaited(cacheHitMiss(hit: cacheHit)); + provider.settings.errorHandler?.call(err); + throw err; + } - if (throwError != null) { - await evict(); + Future finishSuccessfully({ + required Uint8List bytes, + required bool cacheHit, + }) async { + scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); + unawaited(chunkEvents.close()); + finishedLoadingBytes(); - final error = FMTCBrowsingError(throwError, throwErrorType!); - provider.settings.errorHandler?.call(error); - throw error; - } + unawaited( + FMTCBackendAccess.internal + .registerHitOrMiss(storeName: provider.storeName, hit: cacheHit), + ); + return decode(await ImmutableBuffer.fromUint8List(bytes)); + } - if (bytes != null) { - return decode( - await ImmutableBuffer.fromUint8List(Uint8List.fromList(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, ); } - - throw ArgumentError( - '`finish` was called with an invalid combination of arguments, or a fall-through situation occurred.', - ); + return null; } + startedLoading(); + final networkUrl = provider.getTileUrl(coords, options); - final matcherUrl = provider.settings.obscureQueryParams(networkUrl); + final matcherUrl = obscureQueryParams( + url: networkUrl, + obscuredQueryParams: provider.settings.obscuredQueryParams, + ); - final existingTile = await db.tiles.get(DatabaseTools.hash(matcherUrl)); + 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.now().millisecondsSinceEpoch - + DateTime.timestamp().millisecondsSinceEpoch - existingTile.lastModified.millisecondsSinceEpoch > provider.settings.cachedValidDuration.inMilliseconds)); - List? bytes; - if (!needsCreating) bytes = Uint8List.fromList(existingTile.bytes); + // 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) { - return finish( - throwError: - 'Failed to load the tile from the cache because it was missing.', - throwErrorType: FMTCBrowsingErrorType.missingInCacheOnlyMode, - cacheHit: false, + final codec = await attemptFinishViaAltStore(matcherUrl); + if (codec != null) return codec; + + return finishWithError( + FMTCBrowsingError( + type: FMTCBrowsingErrorType.missingInCacheOnlyMode, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + ), ); } - if (needsCreating || needsUpdating) { - final StreamedResponse response; - - try { - response = await provider.httpClient.send( - Request('GET', Uri.parse(networkUrl)) - ..headers.addAll(provider.headers), - ); - } catch (_) { - return finish( - bytes: !needsCreating ? bytes : null, - throwError: needsCreating - ? 'Failed to load the tile from the cache or the network because it was missing from the cache and a connection to the server could not be established.' - : null, - throwErrorType: FMTCBrowsingErrorType.noConnectionDuringFetch, - cacheHit: false, - ); + // 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); } - if (response.statusCode != 200) { - return finish( - bytes: !needsCreating ? bytes : null, - throwError: needsCreating - ? '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 of ${response.statusCode}' - : null, - throwErrorType: FMTCBrowsingErrorType.negativeFetchResponse, - 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, + ), + ); + } - int bytesReceivedLength = 0; - bytes = []; - await for (final byte in response.stream) { - bytesReceivedLength += byte.length; - bytes.addAll(byte); - chunkEvents.add( - ImageChunkEvent( - cumulativeBytesLoaded: bytesReceivedLength, - expectedTotalBytes: response.contentLength, - ), - ); + // Check whether the network response is not 200 OK + if (response.statusCode != 200) { + if (!needsCreating) { + return finishSuccessfully(bytes: bytes!, cacheHit: false); } - unawaited( - db.writeTxn( - () => db.tiles.put(DbTile(url: matcherUrl, bytes: bytes!)), + final codec = await attemptFinishViaAltStore(matcherUrl); + if (codec != null) return codec; + + return finishWithError( + FMTCBrowsingError( + type: FMTCBrowsingErrorType.negativeFetchResponse, + networkUrl: networkUrl, + matcherUrl: matcherUrl, + request: request, + response: response, ), ); + } - if (needsCreating && provider.settings.maxStoreLength != 0) { - unawaited( - _removeOldestQueue.add( - () => compute( - _removeOldestTile, - [ - provider.storeDirectory.storeName, - directory, - provider.settings.maxStoreLength, - ], - ), - ), - ); + // 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); } - return finish(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, + ), + ); } - return finish(bytes: bytes, cacheHit: true); + // 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 @@ -225,34 +288,5 @@ class FMTCImageProvider extends ImageProvider { other.options == options); @override - int get hashCode => Object.hashAllUnordered([ - coords.hashCode, - provider.hashCode, - options.hashCode, - ]); -} - -Future _removeOldestTile(List args) async { - final db = Isar.openSync( - [DbStoreDescriptorSchema, DbTileSchema, DbMetadataSchema], - name: DatabaseTools.hash(args[0]).toString(), - directory: args[1], - inspector: false, - ); - - db.writeTxnSync( - () => db.tiles.deleteAllSync( - db.tiles - .where() - .anyLastModified() - .limit( - (db.tiles.countSync() - args[2]).clamp(0, double.maxFinite).toInt(), - ) - .findAllSync() - .map((t) => t.id) - .toList(), - ), - ); - - await db.close(); + int get hashCode => Object.hash(coords, provider, options); } diff --git a/lib/src/providers/tile_provider.dart b/lib/src/providers/tile_provider.dart index 5f113d36..c25557fa 100644 --- a/lib/src/providers/tile_provider.dart +++ b/lib/src/providers/tile_provider.dart @@ -1,112 +1,141 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; -/// FMTC's custom [TileProvider] for use in a [TileLayer] +/// Specialised [TileProvider] that uses a specialised [ImageProvider] to connect +/// to FMTC internals /// -/// Create from the store directory chain, eg. [StoreDirectory.getTileProvider]. +/// 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 { - /// The store directory attached to this provider - final StoreDirectory storeDirectory; + 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 one provided by [FMTCSettings] when initialising - /// [FlutterMapTileCaching]. + /// Defaults to the ambient [FMTCTileProviderSettings.instance]. final FMTCTileProviderSettings settings; - /// [BaseClient] (such as a [HttpClient]) used to make all network requests + /// [http.Client] (such as a [IOClient]) used to make all network requests /// - /// Defaults to a [HttpPlusClient] which supports HTTP/2 and falls back to a - /// standard [IOClient]/[HttpClient] for HTTP/1.1 servers. Timeout is set to - /// 5 seconds by default. - final BaseClient httpClient; - - FMTCTileProvider._({ - required this.storeDirectory, - required FMTCTileProviderSettings? settings, - Map headers = const {}, - BaseClient? httpClient, - }) : settings = - settings ?? FMTC.instance.settings.defaultTileProviderSettings, - httpClient = httpClient ?? - HttpPlusClient( - http1Client: IOClient( - HttpClient() - ..connectionTimeout = const Duration(seconds: 5) - ..userAgent = null, - ), - connectionTimeout: const Duration(seconds: 5), - ), - super( - headers: { - ...headers, - 'User-Agent': headers['User-Agent'] == null - ? 'flutter_map_tile_caching for flutter_map (unknown)' - : 'flutter_map_tile_caching for ${headers['User-Agent']}', - }, - ); + /// Do not close manually. + /// + /// Defaults to a standard [IOClient]/[HttpClient]. + final http.Client httpClient; - /// Closes the open [httpClient] - this will make the provider unable to - /// perform network requests - @override - void dispose() { - httpClient.close(); - super.dispose(); - } + /// 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>(); - /// Get a browsed tile as an image, paint it on the map and save it's bytes to - /// cache for later (dependent on the [CacheBehavior]) @override - ImageProvider getImage(TileCoordinates coords, TileLayer options) => + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => FMTCImageProvider( provider: this, options: options, - coords: coords, - directory: FMTC.instance.rootDirectory.directory.absolute.path, + coords: coordinates, + startedLoading: () => _tilesInProgress[coordinates] = Completer(), + finishedLoadingBytes: () { + _tilesInProgress[coordinates]?.complete(); + _tilesInProgress.remove(coordinates); + }, ); - /// Check whether a specified tile is cached in the current store synchronously - bool checkTileCached({ + @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, }) => - FMTCRegistry.instance(storeDirectory.storeName).tiles.getSync( - DatabaseTools.hash( - settings.obscureQueryParams(getTileUrl(coords, options)), - ), - ) != - null; + checkTileCached(coords: coords, options: options); /// Check whether a specified tile is cached in the current store - /// asynchronously - Future checkTileCachedAsync({ + Future checkTileCached({ required TileCoordinates coords, required TileLayer options, - }) async => - await FMTCRegistry.instance(storeDirectory.storeName).tiles.get( - DatabaseTools.hash( - settings.obscureQueryParams(getTileUrl(coords, options)), - ), - ) != - null; + }) => + 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.runtimeType == runtimeType && - other.httpClient == httpClient && + other.storeName == storeName && + other.headers == headers && other.settings == settings && - other.storeDirectory == storeDirectory && - other.headers == headers); + other.httpClient == httpClient); @override - int get hashCode => Object.hashAllUnordered([ - httpClient.hashCode, - settings.hashCode, - storeDirectory.hashCode, - headers.hashCode, - ]); + 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_settings.dart b/lib/src/providers/tile_provider_settings.dart new file mode 100644 index 00000000..e482a52a --- /dev/null +++ b/lib/src/providers/tile_provider_settings.dart @@ -0,0 +1,164 @@ +// 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 fce7a24a..9b293fad 100644 --- a/lib/src/regions/base_region.dart +++ b/lib/src/regions/base_region.dart @@ -1,62 +1,60 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; /// A geographical region that forms a particular shape /// /// It can be converted to a: /// - [DownloadableRegion] for downloading: [toDownloadable] /// - [Widget] layer to be placed in a map: [toDrawable] -/// - list of [LatLng]s forming the outline: [toOutline]/[LineRegion.toOutlines] +/// - list of [LatLng]s forming the outline: [toOutline] /// /// Extended/implemented by: /// - [RectangleRegion] /// - [CircleRegion] /// - [LineRegion] -abstract class BaseRegion { +/// - [CustomPolygonRegion] +@immutable +sealed class BaseRegion { /// Create a geographical region that forms a particular shape /// /// It can be converted to a: /// - [DownloadableRegion] for downloading: [toDownloadable] /// - [Widget] layer to be placed in a map: [toDrawable] - /// - list of [LatLng]s forming the outline: [toOutline]/[LineRegion.toOutlines] + /// - list of [LatLng]s forming the outline: [toOutline] /// /// Extended/implemented by: /// - [RectangleRegion] /// - [CircleRegion] /// - [LineRegion] - BaseRegion({required String? name}) - : name = (name?.isEmpty ?? false) - ? throw ArgumentError.value(name, 'name', 'Must not be empty.') - : name; + /// - [CustomPolygonRegion] + const BaseRegion(); - /// The user friendly name for the region - /// - /// This is used within the recovery system, as well as to delete a particular - /// downloaded region within a store. - /// - /// If `null`, this region will have no name. If specified, this must not be - /// empty. - /// - /// _This property is currently redundant, but usage is planned in future - /// versions._ - final String? name; + /// Output a value of type [T] dependent on `this` and its type + 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, + }) => + switch (this) { + RectangleRegion() => rectangle(this as RectangleRegion), + CircleRegion() => circle(this as CircleRegion), + LineRegion() => line(this as LineRegion), + CustomPolygonRegion() => customPolygon(this as CustomPolygonRegion), + }; /// Generate the [DownloadableRegion] ready for bulk downloading /// /// For more information see [DownloadableRegion]'s documentation. - DownloadableRegion toDownloadable( - int minZoom, - int maxZoom, - TileLayer options, { - int parallelThreads = 10, - bool preventRedownload = false, - bool seaTileRemoval = false, - int start = 0, + DownloadableRegion toDownloadable({ + required int minZoom, + required int maxZoom, + required TileLayer options, + int start = 1, int? end, Crs crs = const Epsg3857(), - void Function(Object?)? errorHandler, }); /// Generate a graphical layer to be placed in a [FlutterMap] @@ -69,6 +67,14 @@ abstract class BaseRegion { /// Generate the list of all the [LatLng]s forming the outline of this region /// - /// Returns a `List` which can be used anywhere. - List toOutline(); + /// Returns a `Iterable` which can be used anywhere. + Iterable toOutline(); + + @override + @mustBeOverridden + bool operator ==(Object other); + + @override + @mustBeOverridden + int get hashCode; } diff --git a/lib/src/regions/circle.dart b/lib/src/regions/circle.dart index f3cd487c..ec94b8d1 100644 --- a/lib/src/regions/circle.dart +++ b/lib/src/regions/circle.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; /// A geographically circular region based off a [center] coord and [radius] /// @@ -16,11 +16,7 @@ class CircleRegion extends BaseRegion { /// - [DownloadableRegion] for downloading: [toDownloadable] /// - [Widget] layer to be placed in a map: [toDrawable] /// - list of [LatLng]s forming the outline: [toOutline] - CircleRegion( - this.center, - this.radius, { - super.name, - }); + const CircleRegion(this.center, this.radius); /// Center coordinate final LatLng center; @@ -29,32 +25,22 @@ class CircleRegion extends BaseRegion { final double radius; @override - DownloadableRegion toDownloadable( - int minZoom, - int maxZoom, - TileLayer options, { - int parallelThreads = 10, - bool preventRedownload = false, - bool seaTileRemoval = false, - int start = 0, + DownloadableRegion toDownloadable({ + required int minZoom, + required int maxZoom, + required TileLayer options, + int start = 1, int? end, Crs crs = const Epsg3857(), - void Function(Object?)? errorHandler, }) => DownloadableRegion._( - points: toOutline(), + this, minZoom: minZoom, maxZoom: maxZoom, options: options, - type: RegionType.circle, - originalRegion: this, - parallelThreads: parallelThreads, - preventRedownload: preventRedownload, - seaTileRemoval: seaTileRemoval, start: start, end: end, crs: crs, - errorHandler: errorHandler, ); @override @@ -70,6 +56,7 @@ class CircleRegion extends BaseRegion { PolygonLayer( polygons: [ Polygon( + points: toOutline().toList(), isFilled: fillColor != null, color: fillColor ?? Colors.transparent, borderColor: borderColor, @@ -78,39 +65,28 @@ class CircleRegion extends BaseRegion { label: label, labelStyle: labelStyle, labelPlacement: labelPlacement, - points: toOutline(), - ) + ), ], ); @override - List toOutline() { - final double rad = radius / 1.852 / 3437.670013352; - final double lat = center.latitudeInRad; - final double lon = center.longitudeInRad; - final List output = []; + Iterable toOutline() sync* { + const dist = Distance(roundResult: false, calculator: Haversine()); - for (int x = 0; x <= 360; x++) { - final double brng = x * math.pi / 180; - final double latRadians = math.asin( - math.sin(lat) * math.cos(rad) + - math.cos(lat) * math.sin(rad) * math.cos(brng), - ); - final double lngRadians = lon + - math.atan2( - math.sin(brng) * math.sin(rad) * math.cos(lat), - math.cos(rad) - math.sin(lat) * math.sin(latRadians), - ); + final radius = this.radius * 1000; - output.add( - LatLng( - latRadians * 180 / math.pi, - (lngRadians * 180 / math.pi) - .clamp(-180, 180), // Clamped to fix errors with flutter_map - ), - ); + for (int angle = -180; angle <= 180; angle++) { + yield dist.offset(center, radius, angle); } - - return output; } + + @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 new file mode 100644 index 00000000..3f88324a --- /dev/null +++ b/lib/src/regions/custom_polygon.dart @@ -0,0 +1,79 @@ +// 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] +/// - [Widget] layer to be placed in a map: [toDrawable] +/// - 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] + /// - [Widget] layer to be placed in a map: [toDrawable] + /// - 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, + ); + + @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, + isFilled: fillColor != null, + color: fillColor ?? Colors.transparent, + borderColor: borderColor, + borderStrokeWidth: borderStrokeWidth, + isDotted: isDotted, + 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 2822e017..8ef5d3f0 100644 --- a/lib/src/regions/downloadable_region.dart +++ b/lib/src/regions/downloadable_region.dart @@ -1,34 +1,38 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; - -/// Describes what shape, and therefore rules, a [DownloadableRegion] conforms to -enum RegionType { - /// A region containing 2 points representing the top-left and bottom-right corners of a rectangle - rectangle, - - /// A region containing all the points along it's outline (one every degree) representing a circle - circle, - - /// A region with the border as the loci of a line at it's center representing multiple diagonal rectangles - line, -} +part of '../../flutter_map_tile_caching.dart'; /// A downloadable region to be passed to bulk download functions /// -/// Should avoid manual construction. Use a supported region shape and the `.toDownloadable()` extension on it. -/// -/// Is returned from `.toDownloadable()`. -class DownloadableRegion

> { - /// The shape that this region conforms to - final RegionType type; - - /// The original [BaseRegion], used internally for recovery purposes - final BaseRegion originalRegion; +/// Construct via [BaseRegion.toDownloadable]. +class DownloadableRegion { + DownloadableRegion._( + this.originalRegion, { + required this.minZoom, + required this.maxZoom, + required this.options, + required this.start, + required this.end, + required this.crs, + }) { + if (minZoom > maxZoom) { + throw ArgumentError( + '`minZoom` must be less than or equal to `maxZoom`', + ); + } + if (start < 1 || start > (end ?? double.infinity)) { + throw ArgumentError( + '`start` must be greater or equal to 1 and less than or equal to `end`', + ); + } + } - /// All the vertices on the outline of a polygon - final P points; + /// 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 final int minZoom; @@ -39,109 +43,73 @@ class DownloadableRegion

> { /// The options used to fetch tiles final TileLayer options; - /// The number of download threads allowed to run simultaneously - /// - /// This will significantly increase speed, at the expense of faster battery drain. Note that some servers may forbid multithreading, in which case this should be set to 1, unless another limit is specified. - /// - /// Set to 1 to disable multithreading. Defaults to 10. - final int parallelThreads; - - /// Whether to skip downloading tiles that already exist + /// Optionally skip any tiles before this tile /// - /// Defaults to `false`, so that existing tiles will be updated. - final bool preventRedownload; - - /// Whether to remove tiles that are entirely sea - /// - /// The checks are conducted by comparing the bytes of the tile at x:0, y:0, and z:19 to the bytes of the currently downloading tile. If they match, the tile is deleted, otherwise the tile is kept. - /// - /// This option is therefore not supported when using satellite tiles (because of the variations from tile to tile), on maps where the tile 0/0/19 is not entirely sea, or on servers where zoom level 19 is not supported. If not supported, set this to `false` to avoid wasting unnecessary time and to avoid errors. - /// - /// This is a storage saving feature, not a time saving or data saving feature: tiles still have to be fully downloaded before they can be checked. + /// The order of the tiles in a region is directly chosen by the underlying + /// tile generators, and so may not be stable between updates. /// - /// Set to `false` to keep sea tiles, which is the default. - final bool seaTileRemoval; - - /// Optionally skip past a number of tiles 'at the start' of a region - /// - /// Set to 0 to skip none, which is the default. + /// Set to 1 to skip none, which is the default. final int start; - /// Optionally skip a number of tiles 'at the end' of a region + /// Optionally skip any tiles after this tile + /// + /// The order of the tiles in a region is directly chosen by the underlying + /// tile generators, and so may not be stable between updates. /// /// Set to `null` to skip none, which is the default. final int? end; - /// The map projection to use to calculate tiles. Defaults to `Espg3857()`. + /// The map projection to use to calculate tiles. Defaults to [Epsg3857]. final Crs crs; - /// A function that takes any type of error as an argument to be called in the event a tile fetch fails - final void Function(Object?)? errorHandler; - - DownloadableRegion._({ - required this.points, - required this.minZoom, - required this.maxZoom, - required this.options, - required this.type, - required this.originalRegion, - required this.parallelThreads, - required this.preventRedownload, - required this.seaTileRemoval, - required this.start, - required this.end, - required this.crs, - required this.errorHandler, - }) { - if (minZoom > maxZoom) { - throw ArgumentError( - '`minZoom` should be less than or equal to `maxZoom`', - ); - } - if (parallelThreads < 1) { - throw ArgumentError( - '`parallelThreads` should be more than or equal to 1. Set to 1 to disable multithreading', + /// Cast [originalRegion] from [R] to [N] + @optionalTypeArgs + DownloadableRegion _cast() => DownloadableRegion._( + originalRegion as N, + minZoom: minZoom, + maxZoom: maxZoom, + options: options, + start: start, + end: end, + crs: crs, ); - } - } - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is DownloadableRegion && - other.type == type && - other.originalRegion == originalRegion && - listEquals(other.points, points) && - other.minZoom == minZoom && - other.maxZoom == maxZoom && - other.options == options && - other.parallelThreads == parallelThreads && - other.preventRedownload == preventRedownload && - other.seaTileRemoval == seaTileRemoval && - other.start == start && - other.end == end && - other.crs == crs && - other.errorHandler == errorHandler; - } + /// Output a value of type [T] dependent on [originalRegion] and its type [R] + T when({ + required T Function(DownloadableRegion rectangle) + rectangle, + required T Function(DownloadableRegion circle) circle, + required T Function(DownloadableRegion line) line, + required T Function(DownloadableRegion customPolygon) + customPolygon, + }) => + switch (originalRegion) { + RectangleRegion() => rectangle(_cast()), + CircleRegion() => circle(_cast()), + LineRegion() => line(_cast()), + CustomPolygonRegion() => customPolygon(_cast()), + }; @override - int get hashCode => - type.hashCode ^ - originalRegion.hashCode ^ - points.hashCode ^ - minZoom.hashCode ^ - maxZoom.hashCode ^ - options.hashCode ^ - parallelThreads.hashCode ^ - preventRedownload.hashCode ^ - seaTileRemoval.hashCode ^ - start.hashCode ^ - end.hashCode ^ - crs.hashCode ^ - errorHandler.hashCode; + bool operator ==(Object other) => + identical(this, other) || + (other is DownloadableRegion && + other.originalRegion == originalRegion && + other.minZoom == minZoom && + other.maxZoom == maxZoom && + other.options == options && + other.start == start && + other.end == end && + other.crs == crs); @override - String toString() => - 'DownloadableRegion(type: $type, originalRegion: $originalRegion, points: $points, minZoom: $minZoom, maxZoom: $maxZoom, options: $options, parallelThreads: $parallelThreads, preventRedownload: $preventRedownload, seaTileRemoval: $seaTileRemoval, start: $start, end: $end, crs: $crs, errorHandler: $errorHandler)'; + int get hashCode => Object.hashAllUnordered([ + originalRegion, + minZoom, + maxZoom, + options, + start, + end, + crs, + ]); } diff --git a/lib/src/regions/line.dart b/lib/src/regions/line.dart index 87173b47..467549eb 100644 --- a/lib/src/regions/line.dart +++ b/lib/src/regions/line.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; /// A geographically line/locus region based off a list of coords and a [radius] /// @@ -16,11 +16,7 @@ class LineRegion extends BaseRegion { /// - [DownloadableRegion] for downloading: [toDownloadable] /// - [Widget] layer to be placed in a map: [toDrawable] /// - list of [LatLng]s forming the outline: [LineRegion.toOutlines] - LineRegion( - this.line, - this.radius, { - super.name, - }); + const LineRegion(this.line, this.radius); /// The center line defined by a list of coordinates final List line; @@ -30,83 +26,69 @@ class LineRegion extends BaseRegion { /// 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 + /// 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 (equal gap and overlap) + /// * 0 (default): joined by centers /// * 1 (as downloaded): joined by further corners (largest overlap) - List> toOutlines([int overlap = 0]) { + Iterable> toOutlines([int overlap = 0]) sync* { if (overlap < -1 || overlap > 1) { throw ArgumentError('`overlap` must be between -1 and 1 inclusive'); } - const Distance dist = Distance(); - final int rad = (radius * math.pi / 4).round(); + if (line.isEmpty) return; - return line.map((pos) { - if ((line.indexOf(pos) + 1) >= line.length) return [LatLng(0, 0)]; + const dist = Distance(); + final rad = radius * math.pi / 4; - final List section = [pos, line[line.indexOf(pos) + 1]]; + for (int i = 0; i < line.length - 1; i++) { + final cp = line[i]; + final np = line[i + 1]; - final double bearing = dist.bearing(section[0], section[1]); - final double clockwiseRotation = + final bearing = dist.bearing(cp, np); + final clockwiseRotation = (90 + bearing) > 360 ? 360 - (90 + bearing) : (90 + bearing); - final double anticlockwiseRotation = + final anticlockwiseRotation = (bearing - 90) < 0 ? 360 + (bearing - 90) : (bearing - 90); - final LatLng offset1 = - dist.offset(section[0], rad, clockwiseRotation); // Top-right - final LatLng offset2 = - dist.offset(section[1], rad, clockwiseRotation); // Bottom-right - final LatLng offset3 = - dist.offset(section[1], rad, anticlockwiseRotation); // Bottom-left - final LatLng offset4 = - dist.offset(section[0], rad, anticlockwiseRotation); // Top-left - - if (overlap == 0) return [offset1, offset2, offset3, offset4]; - - final bool r = overlap == -1; - final bool os = line.indexOf(pos) == 0; - final bool oe = line.indexOf(pos) == line.length - 2; - - return [ - os ? offset1 : dist.offset(offset1, r ? rad : -rad, bearing), - oe ? offset2 : dist.offset(offset2, r ? -rad : rad, bearing), - oe ? offset3 : dist.offset(offset3, r ? -rad : rad, bearing), - os ? offset4 : dist.offset(offset4, r ? rad : -rad, bearing), + 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), ]; - }).toList() - ..removeLast(); + } } @override - DownloadableRegion toDownloadable( - int minZoom, - int maxZoom, - TileLayer options, { - int parallelThreads = 10, - bool preventRedownload = false, - bool seaTileRemoval = false, - int start = 0, + DownloadableRegion toDownloadable({ + required int minZoom, + required int maxZoom, + required TileLayer options, + int start = 1, int? end, Crs crs = const Epsg3857(), - void Function(Object?)? errorHandler, }) => DownloadableRegion._( - points: toOutline(), + this, minZoom: minZoom, maxZoom: maxZoom, options: options, - type: RegionType.line, - originalRegion: this, - parallelThreads: parallelThreads, - preventRedownload: preventRedownload, - seaTileRemoval: seaTileRemoval, start: start, end: end, crs: crs, - errorHandler: errorHandler, ); @override @@ -158,19 +140,26 @@ class LineRegion extends BaseRegion { /// Flattens the result of [toOutlines] - its documentation is quoted below /// - /// Prefer [toOutlines]. This method is likely to give a different result than - /// expected if used externally. - /// /// > 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 + /// > Use the optional [overlap] argument to set the behaviour of the joints /// between segments: /// > /// > * -1: joined by closest corners (largest gap), - /// > * 0: joined by centers (equal gap and overlap) - /// > * 1 (default, as downloaded): joined by further corners (most overlap) + /// > * 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 - List toOutline([int overlap = 1]) => - toOutlines(overlap).expand((x) => x).toList(); + int get hashCode => Object.hash(line, radius); } diff --git a/lib/src/regions/recovered_region.dart b/lib/src/regions/recovered_region.dart index d81f69af..357e6d58 100644 --- a/lib/src/regions/recovered_region.dart +++ b/lib/src/regions/recovered_region.dart @@ -1,121 +1,101 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; -/// A mixture between [BaseRegion] and [DownloadableRegion] containing all the salvaged data from a recovered download +/// A mixture between [BaseRegion] and [DownloadableRegion] containing all the +/// salvaged data from a recovered download /// -/// How does recovery work? At the start of a download, a file is created including information about the download. At the end of a download or when a download is correctly cancelled, this file is deleted. However, if there is no ongoing download (controlled by an internal variable) and the recovery file exists, the download has obviously been stopped incorrectly, meaning it can be recovered using the information within the recovery file. +/// See [RootRecovery] for information about the recovery system. /// -/// The availability of [bounds], [line], [center] & [radius] depend on the [type] of the recovered region. -/// -/// Should avoid manual construction. Use [toDownloadable] to restore a valid [DownloadableRegion]. +/// 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 unique ID created for every bulk download operation + /// A mixture between [BaseRegion] and [DownloadableRegion] containing all the + /// salvaged data from a recovered download /// - /// Not actually used when converting to [DownloadableRegion]. + /// See [RootRecovery] for information about the recovery system. + /// + /// The availability of [bounds], [line], [center] & [radius] depend on the + /// represented type of the recovered region. Use [toDownloadable] to restore + /// a valid [DownloadableRegion]. + @internal + RecoveredRegion({ + required this.id, + required this.storeName, + required this.time, + required this.minZoom, + required this.maxZoom, + required this.start, + required this.end, + required this.bounds, + required this.center, + required this.line, + required this.radius, + }); + + /// A unique ID created for every bulk download operation final int id; - /// The store name originally associated with this download. - /// - /// Not actually used when converting to [DownloadableRegion]. + /// The store name originally associated with this download final String storeName; /// The time at which this recovery was started - /// - /// Not actually used when converting to [DownloadableRegion]. final DateTime time; - /// The shape that this region conforms to - final RegionType type; - - /// The bounds for a rectangular region - final LatLngBounds? bounds; - - /// The line making a line-based region - final List? line; - - /// The center of a circular region - final LatLng? center; - - /// The radius of a circular region - final double? radius; - - /// The minimum zoom level to fetch tiles for + /// Corresponds to [DownloadableRegion.minZoom] final int minZoom; - /// The maximum zoom level to fetch tiles for + /// Corresponds to [DownloadableRegion.maxZoom] final int maxZoom; - /// Optionally skip past a number of tiles 'at the start' of a region + /// Corresponds to [DownloadableRegion.start] + /// + /// May not match as originally created, may be the last successful tile. The + /// interval between [start] and [end] is the failed interval. final int start; - /// Optionally skip a number of tiles 'at the end' of a region - final int? end; - - /// The number of download threads allowed to run simultaneously + /// Corresponds to [DownloadableRegion.end] /// - /// This will significantly increase speed, at the expense of faster battery drain. Note that some servers may forbid multithreading, in which case this should be set to 1, unless another limit is specified. - final int parallelThreads; + /// If originally created as `null`, this will be the number of tiles in the + /// region, as determined by [StoreDownload.check]. + final int end; - /// Whether to skip downloading tiles that already exist - final bool preventRedownload; + /// Corresponds to [RectangleRegion.bounds] + final LatLngBounds? bounds; - /// Whether to remove tiles that are entirely sea - /// - /// The checks are conducted by comparing the bytes of the tile at x:0, y:0, and z:19 to the bytes of the currently downloading tile. If they match, the tile is deleted, otherwise the tile is kept. - /// - /// This option is therefore not supported when using satellite tiles (because of the variations from tile to tile), on maps where the tile 0/0/19 is not entirely sea, or on servers where zoom level 19 is not supported. If not supported, set this to `false` to avoid wasting unnecessary time and to avoid errors. - /// - /// This is a storage saving feature, not a time saving or data saving feature: tiles still have to be fully downloaded before they can be checked. - final bool seaTileRemoval; + /// Corrresponds to [LineRegion.line] & [CustomPolygonRegion.outline] + final List? line; - RecoveredRegion._({ - required this.id, - required this.storeName, - required this.time, - required this.type, - required this.bounds, - required this.center, - required this.line, - required this.radius, - required this.minZoom, - required this.maxZoom, - required this.start, - required this.end, - required this.parallelThreads, - required this.preventRedownload, - required this.seaTileRemoval, - }); + /// Corrresponds to [CircleRegion.center] + final LatLng? center; - /// Convert this region into a downloadable region + /// Corrresponds to [LineRegion.radius] & [CircleRegion.radius] + final double? radius; + + /// Convert this region into a [BaseRegion] + /// + /// Determine which type of [BaseRegion] using [BaseRegion.when]. + BaseRegion toRegion() { + if (bounds != null) return RectangleRegion(bounds!); + if (center != null) return CircleRegion(center!, radius!); + if (line != null && radius != null) return LineRegion(line!, radius!); + return CustomPolygonRegion(line!); + } + + /// Convert this region into a [DownloadableRegion] DownloadableRegion toDownloadable( TileLayer options, { Crs crs = const Epsg3857(), - Function(Object?)? errorHandler, - }) { - final BaseRegion region = type == RegionType.rectangle - ? RectangleRegion(bounds!) - : type == RegionType.circle - ? CircleRegion(center!, radius!) - : LineRegion(line!, radius!); - - return DownloadableRegion._( - points: type == RegionType.line - ? (region as LineRegion).toOutlines() - : region.toOutline(), - minZoom: minZoom, - maxZoom: maxZoom, - options: options, - type: type, - originalRegion: region, - parallelThreads: parallelThreads, - preventRedownload: preventRedownload, - seaTileRemoval: seaTileRemoval, - start: start, - end: end, - crs: crs, - errorHandler: errorHandler, - ); - } + }) => + DownloadableRegion._( + toRegion(), + minZoom: minZoom, + maxZoom: maxZoom, + options: options, + start: start, + end: end, + crs: crs, + ); } diff --git a/lib/src/regions/rectangle.dart b/lib/src/regions/rectangle.dart index 0a9145e2..62a0bf0c 100644 --- a/lib/src/regions/rectangle.dart +++ b/lib/src/regions/rectangle.dart @@ -1,7 +1,7 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; /// A geographically rectangular region based off coordinate bounds /// @@ -18,41 +18,28 @@ class RectangleRegion extends BaseRegion { /// - [DownloadableRegion] for downloading: [toDownloadable] /// - [Widget] layer to be placed in a map: [toDrawable] /// - list of [LatLng]s forming the outline: [toOutline] - RectangleRegion( - this.bounds, { - super.name, - }); + const RectangleRegion(this.bounds); /// The coordinate bounds final LatLngBounds bounds; @override - DownloadableRegion toDownloadable( - int minZoom, - int maxZoom, - TileLayer options, { - int parallelThreads = 10, - bool preventRedownload = false, - bool seaTileRemoval = false, - int start = 0, + DownloadableRegion toDownloadable({ + required int minZoom, + required int maxZoom, + required TileLayer options, + int start = 1, int? end, Crs crs = const Epsg3857(), - void Function(Object?)? errorHandler, }) => DownloadableRegion._( - points: [bounds.northWest, bounds.southEast], + this, minZoom: minZoom, maxZoom: maxZoom, options: options, - type: RegionType.rectangle, - originalRegion: this, - parallelThreads: parallelThreads, - preventRedownload: preventRedownload, - seaTileRemoval: seaTileRemoval, start: start, end: end, crs: crs, - errorHandler: errorHandler, ); @override @@ -76,27 +63,20 @@ class RectangleRegion extends BaseRegion { label: label, labelStyle: labelStyle, labelPlacement: labelPlacement, - points: [ - LatLng( - bounds.southEast.latitude, - bounds.northWest.longitude, - ), - bounds.southEast, - LatLng( - bounds.northWest.latitude, - bounds.southEast.longitude, - ), - bounds.northWest, - ], - ) + points: toOutline(), + ), ], ); @override - List toOutline() => [ - LatLng(bounds.southEast.latitude, bounds.northWest.longitude), - bounds.southEast, - LatLng(bounds.northWest.latitude, bounds.southEast.longitude), - bounds.northWest, - ]; + 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/directory.dart b/lib/src/root/directory.dart deleted file mode 100644 index f610c63b..00000000 --- a/lib/src/root/directory.dart +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of flutter_map_tile_caching; - -/// Represents the root directory and root databases -/// -/// Note that this does not provide direct access to any [StoreDirectory]s. -/// -/// The name originates from previous versions of this library, where it -/// represented a real directory instead of a database. -/// -/// Reach through [FlutterMapTileCaching.rootDirectory]. -class RootDirectory { - const RootDirectory._(this.directory); - - /// The real directory beneath which FMTC places all data - usually located - /// within the application's directories - /// - /// Provides low level access. Use with caution, and prefer built-in methods! - /// Corrupting some databases, for example the registry, can lead to data - /// loss from multiple stores. - @internal - @protected - final Directory directory; - - /// Manage the root's representation on the filesystem - /// - /// To create, initialise FMTC. Assume that FMTC is ready after initialisation - /// and before [RootManagement.delete] is called. - RootManagement get manage => const RootManagement._(); - - /// Get statistics about this root (and all sub-stores) - RootStats get stats => const RootStats._(); - - /// Manage the download recovery of all sub-stores - RootRecovery get recovery => RootRecovery.instance ?? RootRecovery._(); - - /// Manage migration for file structure across FMTC versions - RootMigrator get migrator => const RootMigrator._(); - - /// Provides store import functionality for this root - /// - /// The 'fmtc_plus_sharing' module must be installed to add the functionality, - /// without it, this object provides no functionality. - RootImport get import => const RootImport._(); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is RootDirectory && other.directory == directory); - - @override - int get hashCode => directory.hashCode; -} diff --git a/lib/src/root/external.dart b/lib/src/root/external.dart new file mode 100644 index 00000000..3765a7c8 --- /dev/null +++ b/lib/src/root/external.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'; + +/// The result of [RootExternal.import] +/// +/// `storesToStates` will complete when the final store names become available. +/// See [StoresToStates] for more information. +/// +/// `complete` will complete when the import is complete, with the number of +/// imported/overwritten tiles. +typedef ImportResult = ({ + Future storesToStates, + Future complete, +}); + +/// A mapping of the original store name (as exported), to: +/// - its new store `name` (as will be used to import), or `null` if +/// [ImportConflictStrategy.skip] was set (meaning it won't be importing) +/// - whether it `hadConflict` with an existing store +/// +/// Used in [ImportResult]. +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: +/// +/// - Doesn't exist (except [export]): [ImportPathNotExists] +/// - Not a file: [ImportExportPathNotFile] +/// - Not an FMTC archive: [ImportFileNotFMTCStandard] +/// - Not compatible with the current backend: [ImportFileNotBackendCompatible] +/// +/// Importing (especially) and exporting operations are likely to be slow. It is +/// not recommended to attempt to use other FMTC operations during the +/// operation, to avoid slowing it further or potentially causing inconsistent +/// state. +class RootExternal { + const RootExternal._(this.pathToArchive); + + /// The path to an archive file + 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({ + required List storeNames, + }) => + FMTCBackendAccess.internal + .exportStores(storeNames: storeNames, path: pathToArchive); + + /// Imports specified stores and all necessary tiles into the current root + /// + /// See [ImportConflictStrategy] to set how conflicts between existing and + /// importing stores should be resolved. Defaults to + /// [ImportConflictStrategy.rename]. + ImportResult import({ + List? storeNames, + ImportConflictStrategy strategy = ImportConflictStrategy.rename, + }) => + FMTCBackendAccess.internal.importStores( + path: pathToArchive, + storeNames: storeNames, + strategy: strategy, + ); + + /// List the available store names within the archive at [pathToArchive] + Future> get listStores => + FMTCBackendAccess.internal.listImportableStores(path: pathToArchive); +} + +/// Determines what action should be taken when an importing store conflicts +/// with an existing store of the same name +/// +/// See documentation on individual values for more information. +enum ImportConflictStrategy { + /// Skips the importing of the store + skip, + + /// Entirely replaces the existing store with the importing store + /// + /// Tiles from the existing store are deleted if they become orphaned (and do + /// not belong to the importing store). + replace, + + /// Renames the importing store by appending it with the current date & time + /// (which should be unique in all reasonable usecases) + /// + /// All tiles are retained. In the event of a conflict between two tiles, only + /// the one modified most recently is retained. + rename, + + /// Merges the importing and existing stores' tiles and metadata together + /// + /// All tiles are retained. In the event of a conflict between two tiles, only + /// the one modified most recently is retained. + merge; +} diff --git a/lib/src/root/import.dart b/lib/src/root/import.dart deleted file mode 100644 index 6c2bf838..00000000 --- a/lib/src/root/import.dart +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of flutter_map_tile_caching; - -/// Extension access point for the 'fmtc_plus_sharing' module to add store export -/// functionality -/// -/// Does not include any functionality without the module. -class RootImport { - const RootImport._(); -} diff --git a/lib/src/root/manage.dart b/lib/src/root/manage.dart deleted file mode 100644 index ccf19ded..00000000 --- a/lib/src/root/manage.dart +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of flutter_map_tile_caching; - -/// Manages a [RootDirectory]'s representation on the filesystem, such as -/// creation and deletion -class RootManagement { - const RootManagement._(); - - /// Unintialise/close open databases, and delete the root directory and its - /// contents - /// - /// This will remove all traces of this root from the user's device. Use with - /// caution! - Future delete() async { - await FMTCRegistry.instance.uninitialise(delete: true); - await FMTC.instance.rootDirectory.directory.delete(recursive: true); - FMTC._instance = null; - } - - /// Reset the root directory, database, and stores - /// - /// Internally calls [delete] then re-initialises FMTC with the same root - /// directory, [FMTCSettings], and debug mode. Other setup is lost: need to - /// further customise the [FlutterMapTileCaching.initialise]? Use [delete], - /// then re-initialise yourself. - /// - /// This will remove all traces of this root from the user's device. Use with - /// caution! - /// - /// Returns the new [FlutterMapTileCaching] instance. - Future reset() async { - final directory = FMTC.instance.rootDirectory.directory.absolute.path; - final settings = FMTC.instance.settings; - final debugMode = FMTC.instance.debugMode; - - await delete(); - return FMTC.initialise( - rootDirectory: directory, - settings: settings, - debugMode: debugMode, - ); - } -} diff --git a/lib/src/root/migrator.dart b/lib/src/root/migrator.dart deleted file mode 100644 index f21e970d..00000000 --- a/lib/src/root/migrator.dart +++ /dev/null @@ -1,267 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -// ignore_for_file: comment_references - -part of flutter_map_tile_caching; - -/// Manage migration for file structure across FMTC versions -class RootMigrator { - const RootMigrator._(); - - /// Migrates a v6 file structure to a v7 structure - /// - /// Note that this method can be slow on large tilesets, so it's best to offer - /// a choice to your users as to whether they would like to migrate, or just - /// lose all stored tiles. - /// - /// Checks within `getApplicationDocumentsDirectory()` and - /// `getTemporaryDirectory()` for a directory named 'fmtc'. Alternatively, - /// specify a [customDirectory] to search for 'fmtc' within. - /// - /// In order to migrate the tiles to the new format, [urlTemplates] must be - /// used. Pass every URL template used to store any of the tiles that might be - /// in the store. Specifying an empty list will use the preset OSM tile servers - /// only. - /// - /// Set [deleteOldStructure] to `false` to keep the old structure. If a store - /// exists with the same name, it will not be overwritten, and the - /// [deleteOldStructure] parameter will be followed regardless. - /// - /// Only supports placeholders in the normal flutter_map form, those that meet - /// the RegEx: `\{ *([\w_-]+) *\}`. Only supports tiles that were sanitised - /// with the default sanitiser included in FMTC. - /// - /// Recovery information and cached statistics will be lost. - /// - /// Returns `null` if no structure root was found, otherwise a [Map] of the - /// store names to the number of failed tiles (tiles that could not be matched - /// to any of the [urlTemplates]), or `null` if it was skipped because there - /// was an existing store with the same name. A successful migration will have - /// all values 0. - Future?> fromV6({ - required List urlTemplates, - Directory? customDirectory, - bool deleteOldStructure = true, - }) async { - // Prepare the migration regular expressions - final placeholderRegex = RegExp(r'\{ *([\w_-]+) *\}'); - final matchables = [ - ...[ - 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - ...urlTemplates, - ].map((url) { - final sanitised = _defaultFilesystemSanitiser(url).validOutput; - - return [ - sanitised.replaceAll('.', r'\.').replaceAll(placeholderRegex, '.+?'), - sanitised, - url, - ]; - }), - ]; - - // Search for the previous structure - final Directory normal = - (await getApplicationDocumentsDirectory()) >> 'fmtc'; - final Directory temporary = (await getTemporaryDirectory()) >> 'fmtc'; - final Directory? custom = - customDirectory == null ? null : customDirectory >> 'fmtc'; - final Directory? root = await normal.exists() - ? normal - : await temporary.exists() - ? temporary - : custom == null - ? null - : await custom.exists() - ? custom - : null; - if (root == null) return null; - - // Delete recovery files and cached statistics - if (deleteOldStructure) { - final oldRecovery = root >> 'recovery'; - if (await oldRecovery.exists()) await oldRecovery.delete(recursive: true); - final oldStats = root >> 'stats'; - if (await oldStats.exists()) await oldStats.delete(recursive: true); - } - - // Don't continue migration if there are no stores - final oldStores = root >> 'stores'; - if (!await oldStores.exists()) return {}; - - // Prepare results map - final Map results = {}; - - // Migrate stores - await for (final storeDirectory - in oldStores.list().whereType()) { - final name = path.basename(storeDirectory.absolute.path); - results[name] = 0; - - // Ignore this store if a counterpart already exists - if (FMTC.instance(name).manage.ready) { - results[name] = null; - continue; - } - await FMTC.instance(name).manage.createAsync(); - final store = FMTCRegistry.instance(name); - - // Migrate tiles in transaction batches of 250 - await for (final List tiles - in (storeDirectory >> 'tiles').list().whereType().slices(250)) { - await store.writeTxn( - () async => store.tiles.putAll( - (await Future.wait( - tiles.map( - (f) async { - final filename = path.basename(f.absolute.path); - final Map placeholderValues = {}; - - for (final e in matchables) { - if (!RegExp('^${e[0]}\$', multiLine: true) - .hasMatch(filename)) { - continue; - } - - String filenameChangable = filename; - List filenameSplit = filename.split('')..add(''); - - for (final match in placeholderRegex.allMatches(e[1])) { - final templateValue = - e[1].substring(match.start, match.end); - final afterChar = (e[1].split('')..add(''))[match.end]; - - final memory = StringBuffer(); - int i = match.start; - for (; filenameSplit[i] != afterChar; i++) { - memory.write(filenameSplit[i]); - } - filenameChangable = filenameChangable.replaceRange( - match.start, - i, - templateValue, - ); - filenameSplit = filenameChangable.split('')..add(''); - - placeholderValues[templateValue.substring( - 1, - templateValue.length - 1, - )] = memory.toString(); - } - - return DbTile( - url: - TileLayer().templateFunction(e[2], placeholderValues), - bytes: await f.readAsBytes(), - ); - } - - results[name] = results[name]! + 1; - return null; - }, - ), - )) - .whereNotNull() - .toList(), - ), - ); - } - - // Migrate metadata - await store.writeTxn( - () async => store.metadata.putAll( - await (storeDirectory >> 'metadata') - .list() - .whereType() - .asyncMap( - (f) async => DbMetadata( - name: path.basename(f.absolute.path).split('.metadata')[0], - data: await f.readAsString(), - ), - ) - .toList(), - ), - ); - } - - // Delete store files - if (deleteOldStructure && await oldStores.exists()) { - await oldStores.delete(recursive: true); - } - - return results; - } -} - -//! OLD FILESYSTEM SANITISER CODE !// - -_FilesystemSanitiserResult _defaultFilesystemSanitiser(String input) { - final List errorMessages = []; - String validOutput = input; - - // Apply other character rules with general RegExp - validOutput = validOutput.replaceAll(RegExp(r'[\\\\/\:\*\?\"\<\>\|]'), '_'); - if (validOutput != input) { - errorMessages - .add('The name cannot contain invalid characters: \'[NUL]\\/:*?"<>|\''); - } - - // Trim - validOutput = validOutput.trim(); - if (validOutput != input) { - errorMessages.add('The name cannot contain leading and/or trailing spaces'); - } - - // Ensure is not empty - if (validOutput.isEmpty) { - errorMessages.add('The name cannot be empty'); - validOutput = '_'; - } - - // Ensure is not just '.' - if (validOutput.replaceAll('.', '').isEmpty) { - errorMessages.add('The name cannot consist of only periods (.)'); - validOutput = validOutput.replaceAll('.', '_'); - } - - // Reduce string to under 255 chars (keeps end) - if (validOutput.length > 255) { - validOutput = validOutput.substring(validOutput.length - 255); - if (validOutput != input) { - errorMessages.add('The name cannot contain more than 255 characters'); - } - } - - return _FilesystemSanitiserResult( - validOutput: validOutput, - errorMessages: errorMessages, - ); -} - -class _FilesystemSanitiserResult { - final String validOutput; - final List errorMessages; - - _FilesystemSanitiserResult({ - required this.validOutput, - this.errorMessages = const [], - }); - - @override - String toString() => - 'FilesystemSanitiserResult(validOutput: $validOutput, errorMessages: $errorMessages)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is _FilesystemSanitiserResult && - other.validOutput == validOutput && - listEquals(other.errorMessages, errorMessages); - } - - @override - int get hashCode => validOutput.hashCode ^ errorMessages.hashCode; -} diff --git a/lib/src/root/recovery.dart b/lib/src/root/recovery.dart index eff146cb..592e3665 100644 --- a/lib/src/root/recovery.dart +++ b/lib/src/root/recovery.dart @@ -1,168 +1,88 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; -/// Manages the download recovery of all sub-stores of this [RootDirectory] +/// Manages the download recovery of all sub-stores of this [FMTCRoot] /// -/// Is a singleton to ensure functioning as expected. +/// --- +/// +/// When a download is started, a recovery region is stored in a non-volatile +/// database, and the download ID is stored in volatile memory. +/// +/// If the download finishes normally, both entries are removed, otherwise, the +/// memory is cleared when the app is closed, but the database record is not +/// removed. +/// +/// {@template fmtc.rootRecovery.failedDefinition} +/// A failed download is one that was found in the recovery database, but not +/// in the application memory. It can therefore be assumed that the download +/// is also no longer in memory, and was therefore stopped unexpectedly, for +/// example after a fatal crash. +/// {@endtemplate} +/// +/// The recovery system then allows the original [BaseRegion] and +/// [DownloadableRegion] to be recovered (via [RecoveredRegion]) from the failed +/// download, and the download can be restarted. +/// +/// During a download, the database recovery entity is updated every tile (or +/// every batch) with the number of completed tiles: this allows the +/// [DownloadableRegion.start] to have it's value set to skip tiles that have +/// been successfully downloaded. Therefore, no unnecessary tiles are downloaded +/// again. +/// +/// > [!NOTE] +/// > Options set at download time, in [StoreDownload.startForeground], are not +/// > included. class RootRecovery { RootRecovery._() { - instance = this; + _instance = this; } + static RootRecovery? _instance; - Isar get _recovery => FMTCRegistry.instance.recoveryDatabase; + /// Determines which downloads are known to be on-going, and therefore + /// can be ignored when fetching [recoverableRegions] + final Set _downloadsOngoing = {}; - /// Manages the download recovery of all sub-stores of this [RootDirectory] + /// List all recoverable regions, and whether each one has failed /// - /// Is a singleton to ensure functioning as expected. - static RootRecovery? instance; - - /// Keeps a list of downloads that are ongoing, so they are not recoverable - /// unnecessarily - final List _downloadsOngoing = []; - - /// Get a list of all recoverable regions + /// Result can be filtered to only include failed downloads using the + /// [FMTCRecoveryGetFailedExts.failedOnly] extension. /// - /// See [failedRegions] for regions that correspond to failed/stopped downloads. - Future> get recoverableRegions async => - (await _recovery.recovery.where().findAll()) - .map( - (r) => RecoveredRegion._( - id: r.id, - storeName: r.storeName, - time: r.time, - type: r.type, - bounds: r.type == RegionType.rectangle - ? LatLngBounds( - LatLng(r.nwLat!, r.nwLng!), - LatLng(r.seLat!, r.seLng!), - ) - : null, - center: r.type == RegionType.circle - ? LatLng(r.centerLat!, r.centerLng!) - : null, - line: r.type == RegionType.line - ? List.generate( - r.linePointsLat!.length, - (i) => LatLng( - r.linePointsLat![i], - r.linePointsLng![i], - ), - ) - : null, - radius: r.type != RegionType.rectangle - ? r.type == RegionType.circle - ? r.circleRadius! - : r.lineRadius! - : null, - minZoom: r.minZoom, - maxZoom: r.maxZoom, - start: r.start, - end: r.end, - parallelThreads: r.parallelThreads, - preventRedownload: r.preventRedownload, - seaTileRemoval: r.seaTileRemoval, - ), - ) - .toList(); - - /// Get a list of all recoverable regions that correspond to failed/stopped downloads - /// - /// See [recoverableRegions] for all regions. - Future> get failedRegions async => - (await recoverableRegions) - .where((r) => !_downloadsOngoing.contains(r.id)) - .toList(); + /// {@macro fmtc.rootRecovery.failedDefinition} + Future> + get recoverableRegions async => + FMTCBackendAccess.internal.listRecoverableRegions().then( + (rs) => rs.map( + (r) => + (isFailed: !_downloadsOngoing.contains(r.id), region: r), + ), + ); /// Get a specific region, even if it doesn't need recovering /// /// Returns `Future` if there was no region found - Future getRecoverableRegion(int id) async => - (await recoverableRegions).singleWhereOrNull((r) => r.id == id); - - /// Get a specific region, only if it needs recovering - /// - /// Returns `Future` if there was no region found - Future getFailedRegion(int id) async => - (await failedRegions).singleWhereOrNull((r) => r.id == id); + Future<({bool isFailed, RecoveredRegion region})?> getRecoverableRegion( + int id, + ) async { + final region = + await FMTCBackendAccess.internal.getRecoverableRegion(id: id); - Future _start({ - required int id, - required String storeName, - required DownloadableRegion region, - }) async { - _downloadsOngoing.add(id); - return _recovery.writeTxn( - () => _recovery.recovery.put( - DbRecoverableRegion( - id: id, - storeName: storeName, - time: DateTime.now(), - type: region.type, - minZoom: region.minZoom, - maxZoom: region.maxZoom, - start: region.start, - end: region.end, - parallelThreads: region.parallelThreads, - preventRedownload: region.preventRedownload, - seaTileRemoval: region.seaTileRemoval, - nwLat: region.type == RegionType.rectangle - ? (region.originalRegion as RectangleRegion) - .bounds - .northWest - .latitude - : null, - nwLng: region.type == RegionType.rectangle - ? (region.originalRegion as RectangleRegion) - .bounds - .northWest - .longitude - : null, - seLat: region.type == RegionType.rectangle - ? (region.originalRegion as RectangleRegion) - .bounds - .southEast - .latitude - : null, - seLng: region.type == RegionType.rectangle - ? (region.originalRegion as RectangleRegion) - .bounds - .southEast - .longitude - : null, - centerLat: region.type == RegionType.circle - ? (region.originalRegion as CircleRegion).center.latitude - : null, - centerLng: region.type == RegionType.circle - ? (region.originalRegion as CircleRegion).center.longitude - : null, - linePointsLat: region.type == RegionType.line - ? (region.originalRegion as LineRegion) - .line - .map((c) => c.latitude) - .toList() - : null, - linePointsLng: region.type == RegionType.line - ? (region.originalRegion as LineRegion) - .line - .map((c) => c.longitude) - .toList() - : null, - circleRadius: region.type == RegionType.circle - ? (region.originalRegion as CircleRegion).radius - : null, - lineRadius: region.type == RegionType.line - ? (region.originalRegion as LineRegion).radius - : null, - ), - ), - ); + return (isFailed: !_downloadsOngoing.contains(region.id), region: region); } - /// Safely cancel a recoverable region - Future cancel(int id) async { - _downloadsOngoing.remove(id); - return _recovery.writeTxn(() => _recovery.recovery.delete(id)); - } + /// {@macro fmtc.backend.cancelRecovery} + Future cancel(int id) async => + FMTCBackendAccess.internal.cancelRecovery(id: id); +} + +/// Contains [failedOnly] extension for [RootRecovery.recoverableRegions] +/// +/// See documentation on those methods for more information. +extension FMTCRecoveryGetFailedExts + on Iterable<({bool isFailed, RecoveredRegion region})> { + /// Filter the [RootRecovery.recoverableRegions] result to include only + /// failed downloads + Iterable get failedOnly => + where((r) => r.isFailed).map((r) => r.region); } diff --git a/lib/src/root/root.dart b/lib/src/root/root.dart new file mode 100644 index 00000000..e01623d2 --- /dev/null +++ b/lib/src/root/root.dart @@ -0,0 +1,42 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +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. +/// +/// Note that this does not provide direct access to any [FMTCStore]s. +abstract class FMTCRoot { + const FMTCRoot._(); + + /// Get statistics about this root (and all sub-stores) + static RootStats get stats => const RootStats._(); + + /// Manage the download recovery of all sub-stores + static RootRecovery get recovery => + RootRecovery._instance ?? RootRecovery._(); + + /// Export & import 'archives' of selected stores and tiles, outside of the + /// FMTC environment + static RootExternal external({required String pathToArchive}) => + RootExternal._(pathToArchive); +} diff --git a/lib/src/root/statistics.dart b/lib/src/root/statistics.dart index dccbfdb7..fb668bb8 100644 --- a/lib/src/root/statistics.dart +++ b/lib/src/root/statistics.dart @@ -1,119 +1,48 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; -/// Provides statistics about a [RootDirectory] +/// Provides statistics about a [FMTCRoot] class RootStats { const RootStats._(); - FMTCRegistry get _registry => FMTCRegistry.instance; - - /// List all the available [StoreDirectory]s synchronously - /// - /// Prefer [storesAvailableAsync] to avoid blocking the UI thread. Otherwise, - /// this has slightly better performance. - List get storesAvailable => _registry.storeDatabases.values - .map( - (e) => StoreDirectory._( - e.descriptorSync.name, - autoCreate: false, - ), - ) - .toList(); - - /// List all the available [StoreDirectory]s asynchronously - Future> get storesAvailableAsync => Future.wait( - _registry.storeDatabases.values.map( - (e) async => StoreDirectory._( - (await e.descriptor).name, - autoCreate: false, - ), - ), - ); - - /// Retrieve the total size of all stored tiles in kibibytes (KiB) - /// synchronously - /// - /// Prefer [rootSizeAsync] to avoid blocking the UI thread. Otherwise, this - /// has slightly better performance. - /// - /// Internally sums up the size of all stores (using [StoreStats.storeSize]). - double get rootSize => - storesAvailable.map((e) => e.stats.storeSize).sum / 1024; - - /// Retrieve the total size of all stored tiles in kibibytes (KiB) - /// asynchronously - /// - /// Internally sums up the size of all stores (using - /// [StoreStats.storeSizeAsync]). - Future get rootSizeAsync async => - (await Future.wait(storesAvailable.map((e) => e.stats.storeSizeAsync))) - .sum / - 1024; - - /// Retrieve the number of all stored tiles synchronously - /// - /// Prefer [rootLengthAsync] to avoid blocking the UI thread. Otherwise, this - /// has slightly better performance. - /// - /// Internally sums up the length of all stores (using - /// [StoreStats.storeLength]). - int get rootLength => storesAvailable.map((e) => e.stats.storeLength).sum; - - /// Retrieve the number of all stored tiles asynchronously - /// - /// Internally sums up the length of all stores (using - /// [StoreStats.storeLengthAsync]). - Future get rootLengthAsync async => - (await Future.wait(storesAvailable.map((e) => e.stats.storeLengthAsync))) - .sum; - - /// Watch for changes in the current root - /// - /// Useful to update UI only when required, for example, in a `StreamBuilder`. - /// Whenever this has an event, it is likely the other statistics will have - /// changed. - /// - /// Recursively watch specific stores (using [StoreStats.watchChanges]) by - /// providing them as a list of [StoreDirectory]s to [recursive]. To watch all - /// stores, use the [storesAvailable]/[storesAvailableAsync] getter as the - /// argument. By default, no sub-stores are watched (empty list), meaning only - /// events that affect the actual store database (eg. store creations) will be - /// caught. Control where changes are caught from using [storeParts]. See - /// documentation on those parts for their scope. - /// - /// Enable debouncing to prevent unnecessary events for small changes in detail - /// using [debounce]. Defaults to 200ms, or set to null to disable debouncing. - /// - /// Debouncing example (dash roughly represents [debounce]): - /// ```dart - /// input: 1-2-3---4---5-6-| - /// output: ------3---4-----6| - /// ``` - Stream watchChanges({ - Duration? debounce = const Duration(milliseconds: 200), - bool fireImmediately = false, - List recursive = const [], - bool watchRecovery = false, - List storeParts = const [ - StoreParts.metadata, - StoreParts.tiles, - StoreParts.stats, - ], - }) => - StreamGroup.merge([ - DirectoryWatcher(FMTC.instance.rootDirectory.directory.absolute.path) - .events - .where((e) => !path.dirname(e.path).endsWith('import')), - if (watchRecovery) - _registry.recoveryDatabase.recovery - .watchLazy(fireImmediately: fireImmediately), - ...recursive.map( - (s) => s.stats.watchChanges( - fireImmediately: fireImmediately, - storeParts: storeParts, - ), - ), - ]).debounce(debounce ?? Duration.zero); + /// {@macro fmtc.backend.listStores} + Future> get storesAvailable async => + FMTCBackendAccess.internal + .listStores() + .then((s) => s.map(FMTCStore.new).toList()); + + /// {@macro fmtc.backend.realSize} + Future get realSize async => FMTCBackendAccess.internal.realSize(); + + /// {@macro fmtc.backend.rootSize} + Future get size async => FMTCBackendAccess.internal.rootSize(); + + /// {@macro fmtc.backend.rootLength} + Future get length async => FMTCBackendAccess.internal.rootLength(); + + /// {@macro fmtc.backend.watchRecovery} + Stream watchRecovery({ + bool triggerImmediately = false, + }) async* { + final stream = FMTCBackendAccess.internal.watchRecovery( + triggerImmediately: triggerImmediately, + ); + yield* stream; + } + + /// {@macro fmtc.backend.watchStores} + /// + /// If [storeNames] is empty, changes will be watched in all stores. + Stream watchStores({ + List storeNames = const [], + bool triggerImmediately = false, + }) async* { + final stream = FMTCBackendAccess.internal.watchStores( + storeNames: storeNames, + triggerImmediately: triggerImmediately, + ); + yield* stream; + } } diff --git a/lib/src/settings/fmtc_settings.dart b/lib/src/settings/fmtc_settings.dart deleted file mode 100644 index 53c84357..00000000 --- a/lib/src/settings/fmtc_settings.dart +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of flutter_map_tile_caching; - -/// Global FMTC settings -class FMTCSettings { - /// Default settings used when creating an [FMTCTileProvider] - /// - /// Can be overridden on a case-to-case basis when actually creating the tile - /// provider. - final FMTCTileProviderSettings defaultTileProviderSettings; - - /// Sets a strict upper size limit on each underlying database individually - /// (of which there are multiple) - /// - /// It is also recommended to set a limit on the number of tiles instead, using - /// [FMTCTileProviderSettings.maxStoreLength]. If using a generous number - /// there, use a larger number here as well. - /// - /// Setting this value too low may cause unexpected errors when writing to the - /// database. Setting this value too high may cause memory issues on certain - /// older devices or emulators. - /// - /// Defaults to 2GiB (2048MiB). - final int databaseMaxSize; - - /// Sets conditions that will trigger each underlying database (individually) - /// to compact/shrink - /// - /// Isar databases can contain unused space that will be reused for later - /// operations and storage. This operation can be expensive, as the entire - /// database must be copied. Ensure your chosen conditions do not trigger - /// compaction too often. - /// - /// Defaults to triggering compaction when the size of the database file can - /// be halved. - /// - /// Set to `null` to never automatically compact (not recommended). Note that - /// exporting a store will always compact it's underlying database. - final DatabaseCompactCondition? databaseCompactCondition; - - /// Create custom global FMTC settings - FMTCSettings({ - FMTCTileProviderSettings? defaultTileProviderSettings, - this.databaseMaxSize = 2048, - this.databaseCompactCondition = const CompactCondition(minRatio: 2), - }) : defaultTileProviderSettings = - defaultTileProviderSettings ?? FMTCTileProviderSettings(); -} diff --git a/lib/src/settings/tile_provider_settings.dart b/lib/src/settings/tile_provider_settings.dart deleted file mode 100644 index f5cf1842..00000000 --- a/lib/src/settings/tile_provider_settings.dart +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of flutter_map_tile_caching; - -/// Behaviours dictating how and when browse caching should be carried out -enum CacheBehavior { - /// Only get tiles from the local cache - /// - /// Throws [FMTCBrowsingErrorType.missingInCacheOnlyMode] if a tile is not - /// available. - cacheOnly, - - /// Get tiles from the local cache, only using the network to update the cached - /// tile if it has expired ([FMTCTileProviderSettings.cachedValidDuration] has - /// passed) - cacheFirst, - - /// Get tiles from the network where possible, and update the cached tiles - /// - /// Safely falls back to using cached tiles if the network is not available. - onlineFirst, -} - -/// Settings for an [FMTCTileProvider] -class FMTCTileProviderSettings { - /// The behavior method to get and cache a tile - /// - /// Defaults to [CacheBehavior.cacheFirst] - get tiles from the local cache, - /// going on the Internet to update the cached tile if it has expired - /// ([cachedValidDuration] has passed). - final CacheBehavior behavior; - - /// 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 actual store has a size limit of - /// [FMTCSettings.databaseMaxSize], irrespective of this value. - /// - /// 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 [FMTCSettings], 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; - - /// Create settings for an [FMTCTileProvider] - FMTCTileProviderSettings({ - this.behavior = CacheBehavior.cacheFirst, - this.cachedValidDuration = const Duration(days: 16), - this.maxStoreLength = 0, - List obscuredQueryParams = const [], - this.errorHandler, - }) : obscuredQueryParams = obscuredQueryParams.map((e) => RegExp('$e=[^&]*')); - - /// Apply the [obscuredQueryParams] to the input [url] - String obscureQueryParams(String url) { - if (!url.contains('?') || obscuredQueryParams.isEmpty) return url; - - String secondPartUrl = url.split('?')[1]; - for (final r in obscuredQueryParams) { - secondPartUrl = secondPartUrl.replaceAll(r, ''); - } - - return '${url.split('?')[0]}?$secondPartUrl'; - } - - @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.hashCode, - cachedValidDuration.hashCode, - maxStoreLength.hashCode, - errorHandler.hashCode, - obscuredQueryParams.hashCode, - ]); -} diff --git a/lib/src/store/directory.dart b/lib/src/store/directory.dart deleted file mode 100644 index 5de7d083..00000000 --- a/lib/src/store/directory.dart +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of flutter_map_tile_caching; - -/// Represents a store of tiles -/// -/// The tile store itself is a database containing a descriptor, tiles and -/// metadata. -/// -/// The name originates from previous versions of this library, where it -/// represented a real directory instead of a database. -/// -/// Reach through [FlutterMapTileCaching.call]. -class StoreDirectory { - StoreDirectory._( - this.storeName, { - required bool autoCreate, - }) { - if (autoCreate) manage.create(); - } - - /// The user-friendly name of the store directory - final String storeName; - - /// Manage this store's representation on the filesystem - StoreManagement get manage => StoreManagement._(this); - - /// Get statistics about this store - StoreStats get stats => StoreStats._(this); - - /// Manage custom miscellaneous information tied to this store - /// - /// Uses a key-value format where both key and value must be [String]. More - /// advanced requirements should use another package, as this is a basic - /// implementation. - StoreMetadata get metadata => StoreMetadata._(this); - - /// Provides export functionality for this store - /// - /// The 'fmtc_plus_sharing' module must be installed to add the functionality, - /// without it, this object provides no functionality. - StoreExport get export => StoreExport._(this); - - /// Get tools to manage bulk downloading to this store - /// - /// The 'fmtc_plus_background_downloading' module must be installed to add the - /// background downloading functionality. - DownloadManagement get download => DownloadManagement._(this); - - /// Get the [TileProvider] suitable to connect the [TileLayer] to FMTC's - /// internals - /// - /// Uses [FMTCSettings.defaultTileProviderSettings] by default (and it's - /// default if unspecified). Alternatively, override [settings] for this get - /// only. - FMTCTileProvider getTileProvider([ - FMTCTileProviderSettings? settings, - Map? headers, - BaseClient? httpClient, - ]) => - FMTCTileProvider._( - storeDirectory: this, - settings: settings, - headers: headers ?? {}, - httpClient: httpClient, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is StoreDirectory && other.storeName == storeName); - - @override - int get hashCode => storeName.hashCode; -} diff --git a/lib/src/store/download.dart b/lib/src/store/download.dart index 4a8d2fc7..52c2257b 100644 --- a/lib/src/store/download.dart +++ b/lib/src/store/download.dart @@ -1,264 +1,321 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; -/// Provides tools to manage bulk downloading to a specific [StoreDirectory] +/// Provides bulk downloading functionality for a specific [FMTCStore] /// -/// Is a singleton to ensure functioning as expected. +/// --- /// -/// The 'fmtc_plus_background_downloading' module must be installed to add the -/// background downloading functionality. -class DownloadManagement { - /// The store directory to provide access paths to - final StoreDirectory _storeDirectory; - - int? _recoveryId; - // ignore: close_sinks - StreamController? _tileProgressStreamController; - Completer? _cancelRequestSignal; - Completer? _cancelCompleteSignal; - InternalProgressTimingManagement? _progressManagement; - BaseClient? _httpClient; - - factory DownloadManagement._(StoreDirectory storeDirectory) { - if (!_instances.keys.contains(storeDirectory)) { - _instances[storeDirectory] = DownloadManagement.__(storeDirectory); - } - return _instances[storeDirectory]!; - } - - DownloadManagement.__(this._storeDirectory); - - /// Contains the intialised instances of [DownloadManagement]s - static final Map _instances = {}; +/// {@template num_instances} +/// By default, only one download is allowed at any one time. +/// +/// 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. +/// +/// > [!WARNING] +/// > Starting multiple simultaneous downloads may lead to a noticeable +/// > performance loss. Ensure you thoroughly test and profile your application. +/// {@endtemplate} +/// +/// --- +/// +/// Does not keep state. State and download instances are held internally by +/// [DownloadInstance]. +@immutable +class StoreDownload { + const StoreDownload._(this._storeName); + final String _storeName; /// Download a specified [DownloadableRegion] in the foreground, with a - /// recovery session - /// - /// To check the number of tiles that need to be downloaded before using this - /// function, use [check]. + /// recovery session by default /// - /// [httpClient] defaults to a [HttpPlusClient] which supports HTTP/2 and falls - /// back to a standard [IOClient]/[HttpClient] for HTTP/1.1 servers. Timeout is - /// set to 5 seconds 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. This must be listened to. + /// about the download's progression status, once per tile and at intervals + /// of no longer than [maxReportInterval] (after the first tile). + /// + /// --- + /// + /// There are multiple options available to improve the speed of the download. + /// These are ordered from most impact to least impact. + /// + /// - [parallelThreads] (defaults to 5 | 1 to disable): number of simultaneous + /// download threads to run + /// - [maxBufferLength] (defaults to 200 | 0 to disable): number of tiles to + /// temporarily buffer before writing to the store (split evenly between + /// [parallelThreads]) + /// - [skipExistingTiles] (defaults to `false`): whether to skip downloading + /// tiles that are already cached + /// - [skipSeaTiles] (defaults to `true`): whether to skip caching tiles that + /// are entirely sea (based on a comparison to the tile at x0,y0,z17) + /// + /// > [!WARNING] + /// > Using too many parallel threads may place significant strain on the tile + /// > server, so check your tile server's ToS for more information. + /// + /// > [!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. + /// + /// > [!WARNING] + /// > 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. /// /// --- /// - /// [bufferMode] and [bufferLimit] control how this download will use - /// buffering. For information about buffering, and it's advantages and - /// disadvantages, see - /// [this docs page](https://fmtc.jaffaketchup.dev/bulk-downloading/foreground/buffering). - /// Also see [DownloadBufferMode]'s documentation. - /// - /// - If [bufferMode] is [DownloadBufferMode.disabled] (default), [bufferLimit] - /// will be ignored - /// - If [bufferMode] is [DownloadBufferMode.tiles], [bufferLimit] will default - /// to 500 - /// - If [bufferMode] is [DownloadBufferMode.bytes], [bufferLimit] will default - /// to 2000000 (2 MB) + /// 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 + /// [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} + /// + /// --- + /// + /// When this download is started, assuming [disableRecovery] is `false` (as + /// default), the recovery system will register this download, to allow it to + /// be recovered if it unexpectedly fails. + /// + /// For more info, see [RootRecovery]. + /// + /// --- + /// + /// 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]. + /// + /// To set additional headers, set it via [TileProvider.headers] when + /// constructing the [DownloadableRegion]. + /// + /// --- + /// + /// {@macro num_instances} + @useResult Stream startForeground({ required DownloadableRegion region, - FMTCTileProviderSettings? tileProviderSettings, + int parallelThreads = 5, + int maxBufferLength = 200, + bool skipExistingTiles = false, + bool skipSeaTiles = true, + int? rateLimit, + Duration? maxReportInterval = const Duration(seconds: 1), bool disableRecovery = false, - DownloadBufferMode bufferMode = DownloadBufferMode.disabled, - int? bufferLimit, - BaseClient? httpClient, + List? obscuredQueryParams, + Object instanceId = 0, }) async* { - // Start recovery - _recoveryId = DateTime.now().millisecondsSinceEpoch; - if (!disableRecovery) { - await FMTC.instance.rootDirectory.recovery._start( - id: _recoveryId!, - storeName: _storeDirectory.storeName, - region: region, + FMTCBackendAccess.internal; // Verify intialisation + + // Check input arguments for suitability + if (!(region.options.wmsOptions != null || + region.options.urlTemplate != null)) { + throw ArgumentError( + "`.toDownloadable`'s `TileLayer` argument must specify an appropriate `urlTemplate` or `wmsOptions`", + 'region.options.urlTemplate', ); } - // Count number of tiles - final maxTiles = await check(region); - - // Get the tile provider - final FMTCTileProvider tileProvider = - _storeDirectory.getTileProvider(tileProviderSettings); + if (parallelThreads < 1) { + throw ArgumentError.value( + parallelThreads, + 'parallelThreads', + 'must be 1 or greater', + ); + } - // Initialise HTTP client - _httpClient = httpClient ?? - HttpPlusClient( - http1Client: IOClient( - HttpClient() - ..connectionTimeout = const Duration(seconds: 5) - ..userAgent = null, - ), - connectionTimeout: const Duration(seconds: 5), - ); + if (maxBufferLength < 0) { + throw ArgumentError.value( + maxBufferLength, + 'maxBufferLength', + 'must be 0 or greater', + ); + } - // Initialise the sea tile removal system - Uint8List? seaTileBytes; - if (region.seaTileRemoval) { - try { - seaTileBytes = (await _httpClient!.get( - Uri.parse( - tileProvider.getTileUrl( - const TileCoordinates(0, 0, 17), - region.options, - ), - ), - )) - .bodyBytes; - } catch (e) { - seaTileBytes = null; - } + if ((rateLimit ?? 2) < 1) { + throw ArgumentError.value( + rateLimit, + 'rateLimit', + 'must be 1 or greater, or null', + ); } - // Initialise variables - final List failedTiles = []; - int bufferedTiles = 0; - int bufferedSize = 0; - int persistedTiles = 0; - int persistedSize = 0; - int seaTiles = 0; - int existingTiles = 0; - _tileProgressStreamController = StreamController(); - _cancelRequestSignal = Completer(); - _cancelCompleteSignal = Completer(); + // Create download instance + final instance = DownloadInstance.registerIfAvailable(instanceId); + if (instance == null) { + throw StateError( + 'A download instance with ID $instanceId already exists\nTo start ' + 'another download simultaneously, use a unique `instanceId`. Read the ' + 'documentation for additional considerations that should be taken.', + ); + } - // Start progress management - final DateTime startTime = DateTime.now(); - _progressManagement = InternalProgressTimingManagement()..start(); + // Generate recovery ID (unless disabled) + final recoveryId = disableRecovery + ? null + : Object.hash(instanceId, DateTime.timestamp().millisecondsSinceEpoch); - // Start writing isolates - await BulkTileWriter.start( - provider: tileProvider, - bufferMode: bufferMode, - bufferLimit: bufferLimit, - directory: FMTC.instance.rootDirectory.directory.absolute.path, - streamController: _tileProgressStreamController!, + // 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', ); - // Start the bulk downloader - final Stream downloadStream = await bulkDownloader( - streamController: _tileProgressStreamController!, - cancelRequestSignal: _cancelRequestSignal!, - cancelCompleteSignal: _cancelCompleteSignal!, - region: region, - provider: tileProvider, - seaTileBytes: seaTileBytes, - progressManagement: _progressManagement!, - client: _httpClient!, - ); + // Setup control mechanisms (completers) + final cancelCompleter = Completer(); + Completer? pauseCompleter; - // Listen to download progress, and report results - await for (final TileProgress evt in downloadStream) { - if (evt.failedUrl == null) { - if (!evt.wasCancelOperation) { - bufferedTiles++; - } else { - bufferedTiles = 0; - } - bufferedSize += evt.sizeBytes; - if (evt.bulkTileWriterResponse != null) { - persistedTiles = evt.bulkTileWriterResponse![0]; - persistedSize = evt.bulkTileWriterResponse![1]; - } - } else { - failedTiles.add(evt.failedUrl!); + await for (final evt in receivePort) { + // Handle new progress message + if (evt is DownloadProgress) { + yield evt; + continue; } - if (evt.wasSeaTile) seaTiles += 1; - if (evt.wasExistingTile) existingTiles += 1; + // Handle pause comms + if (evt == 1) { + pauseCompleter?.complete(); + continue; + } - final DownloadProgress prog = DownloadProgress._( - downloadID: _recoveryId!, - maxTiles: maxTiles, - successfulTiles: bufferedTiles, - persistedTiles: persistedTiles, - failedTiles: failedTiles, - successfulSize: bufferedSize / 1024, - persistedSize: persistedSize / 1024, - seaTiles: seaTiles, - existingTiles: existingTiles, - duration: DateTime.now().difference(startTime), - tileImage: evt.tileImage == null ? null : MemoryImage(evt.tileImage!), - bufferMode: bufferMode, - progressManagement: _progressManagement!, - ); + // Handle shutdown (both normal and cancellation) + if (evt == null) break; - yield prog; - if (prog.percentageProgress >= 100) break; + // Handle recovery system startup (unless disabled) + if (evt == 2) { + FMTCRoot.recovery._downloadsOngoing.add(recoveryId!); + continue; + } + + // 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; + } + + throw UnimplementedError('Unrecognised message'); } - _internalCancel(); + // Handle shutdown (both normal and cancellation) + receivePort.close(); + if (recoveryId != null) await FMTCRoot.recovery.cancel(recoveryId); + DownloadInstance.unregister(instanceId); + cancelCompleter.complete(); } - /// Check approximately how many downloadable tiles are within a specified - /// [DownloadableRegion] + /// Check how many downloadable tiles are within a specified region /// - /// This does not take into account sea tile removal or redownload prevention, - /// as these are handled in the download area of the code. + /// This does not include skipped sea tiles or skipped existing tiles, as those + /// are handled during download only. /// - /// Returns an `int` which is the number of tiles. + /// Returns the number of tiles. Future check(DownloadableRegion region) => compute( - region.type == RegionType.rectangle - ? TilesCounter.rectangleTiles - : region.type == RegionType.circle - ? TilesCounter.circleTiles - : TilesCounter.lineTiles, + region.when( + rectangle: (_) => TileCounters.rectangleTiles, + circle: (_) => TileCounters.circleTiles, + line: (_) => TileCounters.lineTiles, + customPolygon: (_) => TileCounters.customPolygonTiles, + ), region, ); - /// Cancels the ongoing foreground download and recovery session (within the - /// current object) - /// - /// Do not use to cancel background downloads, return `true` from the - /// background download callback to cancel a background download. Background - /// download cancellations require a few more 'shut-down' steps that can create - /// unexpected issues and memory leaks if not carried out. - /// - /// Note that another instance of this object must be retrieved before another - /// download is attempted, as this one is destroyed. - Future cancel() async { - _cancelRequestSignal?.complete(); - await _cancelCompleteSignal?.future; - - _internalCancel(); - } - - void _internalCancel() { - _progressManagement?.stop(); - - if (_recoveryId != null) { - FMTC.instance.rootDirectory.recovery.cancel(_recoveryId!); - } - _httpClient?.close(); - - _instances.remove(_storeDirectory); - } -} + /// Cancel the ongoing foreground download and recovery session + /// + /// Will return once the cancellation is complete. Note that all running + /// parallel download threads will be allowed to finish their *current* tile + /// download, and buffered tiles will be written. There is no facility to + /// cancel the download immediately, as this would likely cause unwanted + /// behaviour. + /// + /// {@macro num_instances} + /// + /// Does nothing (returns immediately) if there is no ongoing download. + Future cancel({Object instanceId = 0}) async => + await DownloadInstance.get(instanceId)?.requestCancel?.call(); -/// Describes the buffering mode during a bulk download -/// -/// For information about buffering, and it's advantages and disadvantages, see -/// [this docs page](https://fmtc.jaffaketchup.dev/bulk-downloading/foreground/buffering). -enum DownloadBufferMode { - /// Disable the buffer (use direct writing) - /// - /// Tiles will be written directly to the database as soon as they are - /// downloaded. - disabled, + /// Pause the ongoing foreground download + /// + /// 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. + /// + /// {@macro num_instances} + /// + /// 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(); - /// Set the limit of the buffer in terms of the number of tiles it holds + /// Resume (after a [pause]) the ongoing foreground download /// - /// Tiles will be written to an intermediate memory buffer, then bulk written - /// to the database once there are more tiles than specified. - tiles, + /// {@macro num_instances} + /// + /// Does nothing if there is no ongoing download or the download is already + /// running. + void resume({Object instanceId = 0}) => + DownloadInstance.get(instanceId)?.requestResume?.call(); - /// Set the limit of the buffer in terms of the number of bytes it holds + /// Whether the ongoing foreground download is currently paused after a call + /// to [pause] (and prior to [resume]) + /// + /// {@macro num_instances} /// - /// Tiles will be written to an intermediate memory buffer, then bulk written - /// to the database once there are more bytes than specified. - bytes, + /// Also returns `false` if there is no ongoing download. + bool isPaused({Object instanceId = 0}) => + DownloadInstance.get(instanceId)?.isPaused ?? false; } diff --git a/lib/src/store/export.dart b/lib/src/store/export.dart deleted file mode 100644 index cea2337b..00000000 --- a/lib/src/store/export.dart +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright © Luka S (JaffaKetchup) under GPL-v3 -// A full license can be found at .\LICENSE - -part of flutter_map_tile_caching; - -/// Extension access point for the 'fmtc_plus_sharing' module to add store export -/// functionality -/// -/// Does not include any functionality without the module. -class StoreExport { - const StoreExport._(this.storeDirectory); - - /// Used in the 'fmtc_plus_sharing' module - /// - /// Do not use in normal applications. - @internal - @protected - final StoreDirectory storeDirectory; -} diff --git a/lib/src/store/manage.dart b/lib/src/store/manage.dart index e9966997..e8edbacc 100644 --- a/lib/src/store/manage.dart +++ b/lib/src/store/manage.dart @@ -1,299 +1,50 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; -/// Manages a [StoreDirectory]'s representation on the filesystem, such as +/// Manages an [FMTCStore]'s representation on the filesystem, such as /// creation and deletion +/// +/// If the store is not in the expected state (of existence) when invoking an +/// operation, then an error will be thrown ([StoreNotExists]). It is +/// recommended to check [ready] when necessary. class StoreManagement { - StoreManagement._(StoreDirectory storeDirectory) - : _name = storeDirectory.storeName, - _id = DatabaseTools.hash(storeDirectory.storeName), - _registry = FMTCRegistry.instance, - _rootDirectory = FMTC.instance.rootDirectory.directory; + StoreManagement._(this._storeName); + final String _storeName; - final String _name; - final int _id; - final FMTCRegistry _registry; - final Directory _rootDirectory; + /// {@macro fmtc.backend.storeExists} + Future get ready => + FMTCBackendAccess.internal.storeExists(storeName: _storeName); - /// Check whether this store is ready for use - /// - /// It must be registered, and its underlying database must be open, for this - /// method to return `true`. - /// - /// This is a safe method, and will not throw the [FMTCStoreNotReady] error, - /// except in exceptional circumstances. - bool get ready { - try { - _registry(_name); - return true; - // ignore: avoid_catching_errors - } on FMTCStoreNotReady catch (e) { - if (e.registered) rethrow; - return false; - } - } - - /// Create this store asynchronously - /// - /// Does nothing if the store already exists. - Future createAsync() async { - if (ready) return; - - final db = await Isar.open( - [DbStoreDescriptorSchema, DbTileSchema, DbMetadataSchema], - name: _id.toString(), - directory: _rootDirectory.path, - maxSizeMiB: FMTC.instance.settings.databaseMaxSize, - compactOnLaunch: FMTC.instance.settings.databaseCompactCondition, - inspector: false, - ); - await db.writeTxn( - () => db.storeDescriptor.put(DbStoreDescriptor(name: _name)), - ); - _registry.register(_id, db); - } + /// {@macro fmtc.backend.createStore} + Future create() => + FMTCBackendAccess.internal.createStore(storeName: _storeName); - /// Create this store synchronously - /// - /// Prefer [createAsync] to avoid blocking the UI thread. Otherwise, this has - /// slightly better performance. - /// - /// Does nothing if the store already exists. - void create() { - if (ready) return; + /// {@macro fmtc.backend.deleteStore} + Future delete() => + FMTCBackendAccess.internal.deleteStore(storeName: _storeName); - final db = Isar.openSync( - [DbStoreDescriptorSchema, DbTileSchema, DbMetadataSchema], - name: _id.toString(), - directory: _rootDirectory.path, - maxSizeMiB: FMTC.instance.settings.databaseMaxSize, - compactOnLaunch: FMTC.instance.settings.databaseCompactCondition, - inspector: false, - ); - db.writeTxnSync( - () => db.storeDescriptor.putSync(DbStoreDescriptor(name: _name)), - ); - _registry.register(_id, db); - } - - /// Delete this store - /// - /// This will remove all traces of this store from the user's device. Use with - /// caution! - /// - /// Does nothing if the store does not already exist. - Future delete() async { - if (!ready) return; - - final store = _registry.unregister(_id); - if (store?.isOpen ?? false) await store!.close(deleteFromDisk: true); - } - - /// Removes all tiles from this store synchronously - /// - /// Also resets the cache hits & misses statistic. - /// - /// This method requires the store to be [ready], else an [FMTCStoreNotReady] - /// error will be raised. - Future resetAsync() async { - final db = _registry(_name); - await db.writeTxn(() async { - await db.tiles.clear(); - await db.storeDescriptor.put( - (await db.descriptor) - ..hits = 0 - ..misses = 0, - ); - }); - } + /// {@macro fmtc.backend.resetStore} + Future reset() => + FMTCBackendAccess.internal.resetStore(storeName: _storeName); - /// Removes all tiles from this store asynchronously + /// {@macro fmtc.backend.renameStore} /// - /// Also resets the cache hits & misses statistic. - /// - /// Prefer [resetAsync] to avoid blocking the UI thread. Otherwise, this has - /// slightly better performance. - /// - /// This method requires the store to be [ready], else an [FMTCStoreNotReady] - /// error will be raised. - void reset() { - final db = _registry(_name); - db.writeTxnSync(() { - db.tiles.clearSync(); - db.storeDescriptor.putSync( - db.descriptorSync - ..hits = 0 - ..misses = 0, - ); - }); - } - - /// Rename the store directory asynchronously - /// - /// The old [StoreDirectory] will still retain it's link to the old store, so - /// always use the new returned value instead: returns a new [StoreDirectory] + /// 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. - /// - /// This method requires the store to be [ready], else an [FMTCStoreNotReady] - /// error will be raised. - Future rename(String newStoreName) async { - // Unregister and close old database without deleting it - final store = _registry.unregister(_id); - if (store == null) { - _registry(_name); - throw StateError( - 'This error represents a serious internal error in FMTC. Please raise a bug report if seen in any application', - ); - } - await store.close(); - - // Manually change the database's filename - await (_rootDirectory >>> '$_id.isar').rename( - (_rootDirectory >>> '${DatabaseTools.hash(newStoreName)}.isar').path, + Future rename(String newStoreName) async { + await FMTCBackendAccess.internal.renameStore( + currentStoreName: _storeName, + newStoreName: newStoreName, ); - // Register the new database (it will be re-opened) - final newStore = StoreDirectory._(newStoreName, autoCreate: false); - await newStore.manage.createAsync(); - - // Update the name stored inside the database - await _registry(newStoreName).writeTxn( - () => _registry(newStoreName) - .storeDescriptor - .put(DbStoreDescriptor(name: newStoreName)), - ); - - return newStore; - } - - /// Retrieves the most recently modified tile from the store, extracts it's - /// bytes, and renders them to an [Image] - /// - /// Prefer [tileImageAsync] to avoid blocking the UI thread. Otherwise, this - /// has slightly better performance. - /// - /// Eventually returns `null` if there are no cached tiles in this store, - /// otherwise an [Image] with [size] height and width. - /// - /// This method requires the store to be [ready], else an [FMTCStoreNotReady] - /// error will be raised. - Image? tileImage({ - double? size, - Key? key, - double scale = 1.0, - Widget Function(BuildContext, Widget, int?, bool)? frameBuilder, - Widget Function(BuildContext, Object, StackTrace?)? 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, - }) { - final latestTile = _registry(_name) - .tiles - .where(sort: Sort.desc) - .anyLastModified() - .findFirstSync(); - if (latestTile == null) return null; - - return Image.memory( - Uint8List.fromList(latestTile.bytes), - key: key, - scale: scale, - frameBuilder: frameBuilder, - errorBuilder: errorBuilder, - semanticLabel: semanticLabel, - excludeFromSemantics: excludeFromSemantics, - width: size, - height: size, - 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, - ); + return FMTCStore(newStoreName); } - /// Retrieves the most recently modified tile from the store, extracts it's - /// bytes, and renders them to an [Image] - /// - /// Eventually returns `null` if there are no cached tiles in this store, - /// otherwise an [Image] with [size] height and width. - /// - /// This method requires the store to be [ready], else an [FMTCStoreNotReady] - /// error will be raised. - Future tileImageAsync({ - double? size, - Key? key, - double scale = 1.0, - Widget Function(BuildContext, Widget, int?, bool)? frameBuilder, - Widget Function(BuildContext, Object, StackTrace?)? 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, - }) async { - final latestTile = await _registry(_name) - .tiles - .where(sort: Sort.desc) - .anyLastModified() - .findFirst(); - if (latestTile == null) return null; - - return Image.memory( - Uint8List.fromList(latestTile.bytes), - key: key, - scale: scale, - frameBuilder: frameBuilder, - errorBuilder: errorBuilder, - semanticLabel: semanticLabel, - excludeFromSemantics: excludeFromSemantics, - width: size, - height: size, - 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, - ); - } + /// {@macro fmtc.backend.removeTilesOlderThan} + Future removeTilesOlderThan({required DateTime expiry}) => + FMTCBackendAccess.internal + .removeTilesOlderThan(storeName: _storeName, expiry: expiry); } diff --git a/lib/src/store/metadata.dart b/lib/src/store/metadata.dart index 57f47a6c..c4e6c145 100644 --- a/lib/src/store/metadata.dart +++ b/lib/src/store/metadata.dart @@ -1,85 +1,44 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; -/// Manage custom miscellaneous information tied to a [StoreDirectory] +/// Manage custom miscellaneous information tied to an [FMTCStore] /// /// Uses a key-value format where both key and value must be [String]. More /// advanced requirements should use another package, as this is a basic /// implementation. -class StoreMetadata extends _StoreDb { - const StoreMetadata._(super._store); +class StoreMetadata { + StoreMetadata._(this._storeName); + final String _storeName; - /// Add a new key-value pair to the store asynchronously - /// - /// Overwrites the value if the key already exists. - Future addAsync({ - required String key, - required String value, - }) => - _db.writeTxn( - () => _db.metadata.put(DbMetadata(name: key, data: value)), - ); + /// {@macro fmtc.backend.readMetadata} + Future> get read => + FMTCBackendAccess.internal.readMetadata(storeName: _storeName); - /// Add a new key-value pair to the store synchronously - /// - /// Overwrites the value if the key already exists. - /// - /// Prefer [addAsync] to avoid blocking the UI thread. Otherwise, this has - /// slightly better performance. - void add({ + /// {@macro fmtc.backend.setMetadata} + Future set({ required String key, required String value, }) => - _db.writeTxnSync( - () => _db.metadata.putSync(DbMetadata(name: key, data: value)), - ); - - /// Remove a new key-value pair from the store asynchronously - Future removeAsync({required String key}) => - _db.writeTxn(() => _db.metadata.delete(DatabaseTools.hash(key))); - - /// Remove a new key-value pair from the store synchronously - /// - /// Prefer [removeAsync] to avoid blocking the UI thread. Otherwise, this has - /// slightly better performance. - void remove({required String key}) => _db.writeTxnSync( - () => _db.metadata.deleteSync(DatabaseTools.hash(key)), - ); + FMTCBackendAccess.internal + .setMetadata(storeName: _storeName, key: key, value: value); - /// Remove all the key-value pairs from the store asynchronously - Future resetAsync() => _db.writeTxn( - () async => Future.wait( - (await _db.metadata.where().findAll()) - .map((m) => _db.metadata.delete(m.id)), - ), - ); - - /// Remove all the key-value pairs from the store synchronously - /// - /// Prefer [resetAsync] to avoid blocking the UI thread. Otherwise, this has - /// slightly better performance. - void reset() => _db.writeTxnSync( - () => Future.wait( - _db.metadata - .where() - .findAllSync() - .map((m) => _db.metadata.delete(m.id)), - ), - ); + /// {@macro fmtc.backend.setBulkMetadata} + Future setBulk({ + required Map kvs, + }) => + FMTCBackendAccess.internal + .setBulkMetadata(storeName: _storeName, kvs: kvs); - /// Read all the key-value pairs from the store asynchronously - Future> get readAsync async => Map.fromEntries( - (await _db.metadata.where().findAll()) - .map((m) => MapEntry(m.name, m.data)), - ); + /// {@macro fmtc.backend.removeMetadata} + Future remove({ + required String key, + }) => + FMTCBackendAccess.internal + .removeMetadata(storeName: _storeName, key: key); - /// Read all the key-value pairs from the store synchronously - /// - /// Prefer [readAsync] to avoid blocking the UI thread. Otherwise, this has - /// slightly better performance. - Map get read => Map.fromEntries( - _db.metadata.where().findAllSync().map((m) => MapEntry(m.name, m.data)), - ); + /// {@macro fmtc.backend.resetMetadata} + Future reset() => + FMTCBackendAccess.internal.resetMetadata(storeName: _storeName); } diff --git a/lib/src/store/statistics.dart b/lib/src/store/statistics.dart index c2f53c85..f59119af 100644 --- a/lib/src/store/statistics.dart +++ b/lib/src/store/statistics.dart @@ -1,104 +1,112 @@ // Copyright © Luka S (JaffaKetchup) under GPL-v3 // A full license can be found at .\LICENSE -// Not sure why the hell this triggers! It triggers on a documentation comment, -// and doesn't go away no matter what I do. // ignore_for_file: use_late_for_private_fields_and_variables -part of flutter_map_tile_caching; +part of '../../flutter_map_tile_caching.dart'; -/// Provides statistics about a [StoreDirectory] -class StoreStats extends _StoreDb { - const StoreStats._(super._store); +/// Provides statistics about an [FMTCStore] +/// +/// If the store is not in the expected state (of existence) when invoking an +/// operation, then an error will be thrown ([StoreNotExists]). It is +/// recommended to check [StoreManagement.ready] when necessary. +class StoreStats { + StoreStats._(this._storeName); + final String _storeName; - /// Retrieve the total size of the stored tiles and metadata in kibibytes (KiB) + /// {@macro fmtc.backend.getStoreStats} /// - /// Prefer [storeSizeAsync] to avoid blocking the UI thread. Otherwise, this - /// has slightly better performance. - double get storeSize => _db.getSizeSync(includeIndexes: true) / 1024; + /// {@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. + /// {@endtemplate} + Future<({double size, int length, int hits, int misses})> get all => + FMTCBackendAccess.internal.getStoreStats(storeName: _storeName); - /// Retrieve the total size of the stored tiles and metadata in kibibytes (KiB) - Future get storeSizeAsync async => - await _db.getSize(includeIndexes: true) / 1024; - - /// Retrieve the number of stored tiles synchronously + /// Retrieve the total number of KiBs of all tiles' bytes (not 'real total' + /// size) /// - /// Prefer [storeLengthAsync] to avoid blocking the UI thread. Otherwise, this - /// has slightly better performance. - int get storeLength => _db.tiles.countSync(); - - /// Retrieve the number of stored tiles asynchronously - Future get storeLengthAsync => _db.tiles.count(); + /// {@macro fmtc.frontend.storestats.efficiency} + Future get size => all.then((a) => a.size); - /// Retrieve the number of tiles that were successfully retrieved from the - /// store during browsing synchronously + /// Retrieve the number of tiles belonging to this store /// - /// Prefer [cacheHitsAsync] to avoid blocking the UI thread. Otherwise, this - /// has slightly better performance. - int get cacheHits => _db.descriptorSync.hits; - - /// Retrieve the number of tiles that were successfully retrieved from the - /// store during browsing asynchronously - Future get cacheHitsAsync async => (await _db.descriptor).hits; + /// {@macro fmtc.frontend.storestats.efficiency} + Future get length => all.then((a) => a.length); - /// Retrieve the number of tiles that were unsuccessfully retrieved from the - /// store during browsing synchronously + /// Retrieve the number of successful tile retrievals when browsing /// - /// Prefer [cacheMissesAsync] to avoid blocking the UI thread. Otherwise, this - /// has slightly better performance. - int get cacheMisses => _db.descriptorSync.misses; + /// {@macro fmtc.frontend.storestats.efficiency} + Future get hits => all.then((a) => a.hits); - /// Retrieve the number of tiles that were unsuccessfully retrieved from the - /// store during browsing asynchronously - Future get cacheMissesAsync async => (await _db.descriptor).misses; - - /// Watch for changes in the current store - /// - /// Useful to update UI only when required, for example, in a `StreamBuilder`. - /// Whenever this has an event, it is likely the other statistics will have - /// changed. + /// Retrieve the number of unsuccessful tile retrievals when browsing /// - /// Control where changes are caught from using [storeParts]. See documentation - /// on those parts for their scope. - /// - /// Enable debouncing to prevent unnecessary events for small changes in detail - /// using [debounce]. Defaults to 200ms, or set to null to disable debouncing. - /// - /// Debouncing example (dash roughly represents [debounce]): - /// ```dart - /// input: 1-2-3---4---5-6-| - /// output: ------3---4-----6| - /// ``` - Stream watchChanges({ - Duration? debounce = const Duration(milliseconds: 200), - bool fireImmediately = false, - List storeParts = const [ - StoreParts.metadata, - StoreParts.tiles, - StoreParts.stats, - ], - }) => - StreamGroup.merge([ - if (storeParts.contains(StoreParts.metadata)) - _db.metadata.watchLazy(fireImmediately: fireImmediately), - if (storeParts.contains(StoreParts.tiles)) - _db.tiles.watchLazy(fireImmediately: fireImmediately), - if (storeParts.contains(StoreParts.stats)) - _db.storeDescriptor - .watchObjectLazy(0, fireImmediately: fireImmediately), - ]).debounce(debounce ?? Duration.zero); -} + /// {@macro fmtc.frontend.storestats.efficiency} + Future get misses => all.then((a) => a.misses); -/// Parts of a store which can be watched -enum StoreParts { - /// Include changes to the store's metadata objects - metadata, + /// {@macro fmtc.backend.watchStores} + Stream watchChanges({ + bool triggerImmediately = false, + }) async* { + final stream = FMTCBackendAccess.internal.watchStores( + storeNames: [_storeName], + triggerImmediately: triggerImmediately, + ); + yield* stream; + } - /// Includes changes to the store's tile objects, including those which will - /// make some statistics change (eg. store size) - tiles, + /// {@macro fmtc.backend.readLatestTile} + /// , then render the bytes to an [Image] + Future tileImage({ + 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, + }) async { + final latestTile = + await FMTCBackendAccess.internal.readLatestTile(storeName: _storeName); + if (latestTile == null) return null; - /// Includes changes to the store's descriptor object, which will change with - /// the cache hit and miss statistics - stats, + return Image.memory( + Uint8List.fromList(latestTile.bytes), + key: key, + scale: scale, + frameBuilder: frameBuilder, + errorBuilder: errorBuilder, + semanticLabel: semanticLabel, + excludeFromSemantics: excludeFromSemantics, + width: size, + height: size, + 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, + ); + } } diff --git a/lib/src/store/store.dart b/lib/src/store/store.dart new file mode 100644 index 00000000..ea88e25f --- /dev/null +++ b/lib/src/store/store.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'; + +/// 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 +/// [storeName] +/// +/// > [!IMPORTANT] +/// > Constructing an instance of this class will not automatically create it. +/// > To create this store, use [manage] > [StoreManagement.create]. +/// {@endtemplate} +class FMTCStore { + /// {@macro fmtc.fmtcStore} + const FMTCStore(this.storeName); + + /// The user-friendly name of the store directory + final String storeName; + + /// Manage this store's representation on the filesystem + StoreManagement get manage => StoreManagement._(storeName); + + /// Get statistics about this store + StoreStats get stats => StoreStats._(storeName); + + /// Manage custom miscellaneous information tied to this store + /// + /// Uses a key-value format where both key and value must be [String]. More + /// advanced requirements should use another package, as this is a basic + /// implementation. + StoreMetadata get metadata => StoreMetadata._(storeName); + + /// Provides bulk downloading functionality + StoreDownload get download => StoreDownload._(storeName); + + /// Generate a [TileProvider] that connects to FMTC internals + /// + /// [settings] defaults to the current ambient + /// [FMTCTileProviderSettings.instance], which defaults to the initial + /// configuration if no other instance has been set. + FMTCTileProvider getTileProvider({ + FMTCTileProviderSettings? settings, + Map? headers, + http.Client? httpClient, + }) => + FMTCTileProvider._(storeName, settings, headers, httpClient); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FMTCStore && other.storeName == storeName); + + @override + int get hashCode => storeName.hashCode; + + @override + String toString() => 'FMTCStore(storeName: $storeName)'; +} diff --git a/pubspec.yaml b/pubspec.yaml index 9d7e5fc0..f3084559 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,8 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 8.0.1 +version: 9.0.0 + repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues documentation: https://fmtc.jaffaketchup.dev @@ -9,6 +10,11 @@ documentation: https://fmtc.jaffaketchup.dev funding: - https://github.com/sponsors/JaffaKetchup +topics: + - flutter-map + - map + - fmtc + platforms: android: ios: @@ -17,30 +23,31 @@ platforms: windows: environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=3.7.0" + sdk: ">=3.3.0 <4.0.0" + flutter: ">=3.19.0" dependencies: - async: ^2.9.0 - collection: ^1.16.0 + async: ^2.11.0 + collection: ^1.18.0 + dart_earcut: ^1.1.0 + flat_buffers: ^23.5.26 flutter: sdk: flutter - flutter_map: ^4.0.0 - http: ^0.13.5 - http_plus: ^0.2.2 - isar: ^3.1.0+1 - isar_flutter_libs: ^3.1.0+1 - latlong2: ^0.8.0 - meta: ^1.7.0 - path: ^1.8.2 - path_provider: ^2.0.7 - queue: ^3.1.0+1 - stream_transform: ^2.0.0 - watcher: ^1.0.2 + flutter_map: ^6.1.0 + http: ^1.2.1 + latlong2: ^0.9.1 + meta: ^1.11.0 + objectbox: ^2.5.1 + objectbox_flutter_libs: ^2.5.1 + path: ^1.9.0 + path_provider: ^2.1.2 dev_dependencies: - build_runner: ^2.3.2 - flutter_lints: ^2.0.1 - isar_generator: ^3.1.0+1 + build_runner: ^2.4.8 + objectbox_generator: ^2.5.0 + test: ^1.25.2 + +flutter: null -flutter: null \ No newline at end of file +objectbox: + output_dir: src/backend/impls/objectbox/models/generated diff --git a/test/general_test.dart b/test/general_test.dart new file mode 100644 index 00000000..fd0bc64f --- /dev/null +++ b/test/general_test.dart @@ -0,0 +1,660 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_map_tile_caching/custom_backend_api.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +// To install ObjectBox dependencies: +// * use bash terminal +// * cd to test/ +// * run `bash <(curl -s https://raw.githubusercontent.com/objectbox/objectbox-dart/main/install.sh) --quiet` + +void main() { + setUpAll(() { + // Necessary to locate the ObjectBox libs + Directory.current = + Directory(p.join(Directory.current.absolute.path, 'test')); + }); + + group( + 'Basic store usage & root stats consistency', + () { + setUpAll( + () => FMTCObjectBoxBackend().initialise(useInMemoryDatabase: true), + ); + + test( + 'Initially zero/empty', + () async { + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect(await FMTCRoot.stats.storesAvailable, []); + }, + ); + + test( + 'Create "store1"', + () async { + await const FMTCStore('store1').manage.create(); + + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect( + await FMTCRoot.stats.storesAvailable, + [const FMTCStore('store1')], + ); + }, + ); + + test( + 'Duplicate creation allowed', + () async { + await const FMTCStore('store1').manage.create(); + + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect( + await FMTCRoot.stats.storesAvailable, + [const FMTCStore('store1')], + ); + }, + ); + + test( + 'Create "store2"', + () async { + await const FMTCStore('store2').manage.create(); + + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect( + await FMTCRoot.stats.storesAvailable, + [const FMTCStore('store1'), const FMTCStore('store2')], + ); + }, + ); + + test( + 'Delete "store2"', + () async { + await const FMTCStore('store2').manage.delete(); + + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect( + await FMTCRoot.stats.storesAvailable, + [const FMTCStore('store1')], + ); + }, + ); + + test( + 'Duplicate deletion allowed', + () async { + await const FMTCStore('store2').manage.delete(); + + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect( + await FMTCRoot.stats.storesAvailable, + [const FMTCStore('store1')], + ); + }, + ); + + test( + 'Cannot reset/rename "store2"', + () async { + expect( + () => const FMTCStore('store2').manage.reset(), + throwsA(const TypeMatcher()), + ); + expect( + () => const FMTCStore('store2').manage.rename('store0'), + throwsA(const TypeMatcher()), + ); + + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect( + await FMTCRoot.stats.storesAvailable, + [const FMTCStore('store1')], + ); + }, + ); + + test( + 'Reset "store1"', + () async { + await const FMTCStore('store1').manage.reset(); + + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect( + await FMTCRoot.stats.storesAvailable, + [const FMTCStore('store1')], + ); + }, + ); + + test( + 'Rename "store1" to "store3"', + () async { + await const FMTCStore('store1').manage.rename('store3'); + + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect( + await FMTCRoot.stats.storesAvailable, + [const FMTCStore('store3')], + ); + }, + ); + + tearDownAll( + () => FMTCObjectBoxBackend() + .uninitialise(deleteRoot: true, immediate: true), + ); + }, + timeout: const Timeout(Duration(seconds: 1)), + ); + + group( + 'Metadata', + () { + setUpAll(() async { + await FMTCObjectBoxBackend().initialise(useInMemoryDatabase: true); + await const FMTCStore('store').manage.create(); + }); + + test( + 'Initially empty', + () async { + expect(await const FMTCStore('store').metadata.read, {}); + }, + ); + + test( + 'Write', + () async { + await const FMTCStore('store') + .metadata + .set(key: 'key', value: 'value'); + expect( + await const FMTCStore('store').metadata.read, + {'key': 'value'}, + ); + }, + ); + + test( + 'Overwrite', + () async { + await const FMTCStore('store') + .metadata + .set(key: 'key', value: 'value2'); + expect( + await const FMTCStore('store').metadata.read, + {'key': 'value2'}, + ); + }, + ); + + test( + 'Bulk (over)write', + () async { + await const FMTCStore('store') + .metadata + .setBulk(kvs: {'key': 'value3', 'key2': 'value4'}); + expect( + await const FMTCStore('store').metadata.read, + {'key': 'value3', 'key2': 'value4'}, + ); + }, + ); + + test( + 'Remove existing', + () async { + expect( + await const FMTCStore('store').metadata.remove(key: 'key2'), + 'value4', + ); + expect( + await const FMTCStore('store').metadata.read, + {'key': 'value3'}, + ); + }, + ); + + test( + 'Remove non-existent', + () async { + expect( + await const FMTCStore('store').metadata.remove(key: 'key3'), + null, + ); + expect( + await const FMTCStore('store').metadata.read, + {'key': 'value3'}, + ); + }, + ); + + test( + 'Reset', + () async { + await const FMTCStore('store').metadata.reset(); + expect(await const FMTCStore('store').metadata.read, {}); + }, + ); + + tearDownAll( + () => FMTCObjectBoxBackend() + .uninitialise(deleteRoot: true, immediate: true), + ); + }, + timeout: const Timeout(Duration(seconds: 1)), + ); + + group( + 'Tile operations & stats consistency', + () { + setUpAll(() async { + await FMTCObjectBoxBackend().initialise(useInMemoryDatabase: true); + await const FMTCStore('store1').manage.create(); + await const FMTCStore('store2').manage.create(); + }); + + final tileA64 = + (url: 'https://example.com/0/0/0.png', bytes: Uint8List(64)); + final tileA128 = + (url: 'https://example.com/0/0/0.png', bytes: Uint8List(128)); + final tileB64 = ( + url: 'https://example.com/1/1/1.png', + bytes: Uint8List.fromList(List.filled(64, 1)), + ); + final tileB128 = ( + url: 'https://example.com/1/1/1.png', + bytes: Uint8List.fromList(List.filled(128, 1)), + ); + + test( + 'Initially semi-zero/empty', + () async { + expect( + await const FMTCStore('store1').stats.all, + (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 const FMTCStore('store1').stats.tileImage(), null); + expect(await const FMTCStore('store2').stats.tileImage(), null); + expect(await FMTCRoot.stats.length, 0); + expect(await FMTCRoot.stats.size, 0); + expect( + await FMTCRoot.stats.storesAvailable, + [const FMTCStore('store1'), const FMTCStore('store2')], + ); + }, + ); + + test( + 'Write tile (A64) to "store1"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store1', + url: tileA64.url, + bytes: tileA64.bytes, + ); + expect( + await const FMTCStore('store1').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( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA64.bytes, + ); + }, + ); + + test( + 'Write tile (A64) again to "store1"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store1', + url: tileA64.url, + bytes: tileA64.bytes, + ); + expect( + await const FMTCStore('store1').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( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA64.bytes, + ); + }, + ); + + test( + 'Write tile (A128) to "store1"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store1', + url: tileA128.url, + bytes: tileA128.bytes, + ); + expect( + await const FMTCStore('store1').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, + ); + }, + ); + + test( + 'Write tile (B64) to "store1"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store1', + url: tileB64.url, + bytes: tileB64.bytes, + ); + expect( + await const FMTCStore('store1').stats.all, + (length: 2, size: 0.1875, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 2); + expect(await FMTCRoot.stats.size, 0.1875); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileB64.bytes, + ); + }, + ); + + test( + 'Write tile (B128) to "store1"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store1', + url: tileB128.url, + bytes: tileB128.bytes, + ); + expect( + await const FMTCStore('store1').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, + ); + }, + ); + + test( + 'Write tile (B64) again to "store1"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store1', + url: tileB64.url, + bytes: tileB64.bytes, + ); + expect( + await const FMTCStore('store1').stats.all, + (length: 2, size: 0.1875, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 2); + expect(await FMTCRoot.stats.size, 0.1875); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileB64.bytes, + ); + }, + ); + + test( + 'Delete tile (B(64)) from "store1"', + () async { + await FMTCBackendAccess.internal.deleteTile( + storeName: 'store1', + url: tileB128.url, + ); + expect( + await const FMTCStore('store1').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, + ); + }, + ); + + test( + 'Write tile (A64) to "store2"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store2', + url: tileA64.url, + bytes: tileA64.bytes, + ); + expect( + await const FMTCStore('store1').stats.all, + (length: 1, size: 0.0625, hits: 0, misses: 0), + ); + expect( + await const FMTCStore('store2').stats.all, + (length: 1, size: 0.0625, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 1); + expect(await FMTCRoot.stats.size, 0.0625); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA64.bytes, + ); + expect( + ((await const FMTCStore('store2').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA64.bytes, + ); + }, + ); + + test( + 'Write tile (A128) to "store2"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store2', + url: tileA128.url, + bytes: tileA128.bytes, + ); + expect( + await const FMTCStore('store1').stats.all, + (length: 1, size: 0.125, hits: 0, misses: 0), + ); + expect( + await const FMTCStore('store2').stats.all, + (length: 1, size: 0.125, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 1); + expect(await FMTCRoot.stats.size, 0.125); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA128.bytes, + ); + expect( + ((await const FMTCStore('store2').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA128.bytes, + ); + }, + ); + + test( + 'Delete tile (A(128)) from "store2"', + () async { + await FMTCBackendAccess.internal.deleteTile( + storeName: 'store2', + 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: 0, size: 0, 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(), null); + }, + ); + + test( + 'Write tile (B64) to "store2"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store2', + url: tileB64.url, + bytes: tileB64.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.0625, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 2); + expect(await FMTCRoot.stats.size, 0.1875); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA128.bytes, + ); + expect( + ((await const FMTCStore('store2').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileB64.bytes, + ); + }, + ); + + test( + 'Write tile (A64) to "store2"', + () async { + await FMTCBackendAccess.internal.writeTile( + storeName: 'store2', + url: tileA64.url, + bytes: tileA64.bytes, + ); + expect( + await const FMTCStore('store1').stats.all, + (length: 1, size: 0.0625, hits: 0, misses: 0), + ); + expect( + await const FMTCStore('store2').stats.all, + (length: 2, size: 0.125, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 2); + expect(await FMTCRoot.stats.size, 0.125); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA64.bytes, + ); + expect( + ((await const FMTCStore('store2').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA64.bytes, + ); + }, + ); + + test( + 'Reset "store2"', + () async { + await const FMTCStore('store2').manage.reset(); + expect( + await const FMTCStore('store1').stats.all, + (length: 1, size: 0.0625, hits: 0, misses: 0), + ); + expect( + await const FMTCStore('store2').stats.all, + (length: 0, size: 0, hits: 0, misses: 0), + ); + expect(await FMTCRoot.stats.length, 1); + expect(await FMTCRoot.stats.size, 0.0625); + expect( + ((await const FMTCStore('store1').stats.tileImage())?.image + as MemoryImage?) + ?.bytes, + tileA64.bytes, + ); + expect(await const FMTCStore('store2').stats.tileImage(), null); + }, + ); + + tearDownAll( + () => FMTCObjectBoxBackend() + .uninitialise(deleteRoot: true, immediate: true), + ); + }, + timeout: const Timeout(Duration(seconds: 1)), + ); +} diff --git a/test/region_tile_test.dart b/test/region_tile_test.dart new file mode 100644 index 00000000..3ec60989 --- /dev/null +++ b/test/region_tile_test.dart @@ -0,0 +1,388 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +// ignore_for_file: avoid_print + +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:latlong2/latlong.dart'; +import 'package:test/test.dart'; + +void main() { + Future countByGenerator(DownloadableRegion region) async { + final tilereceivePort = ReceivePort(); + final tileIsolate = await Isolate.spawn( + region.when( + rectangle: (_) => TileGenerators.rectangleTiles, + circle: (_) => TileGenerators.circleTiles, + line: (_) => TileGenerators.lineTiles, + customPolygon: (_) => TileGenerators.customPolygonTiles, + ), + (sendPort: 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) { + if (evt == null) break; + if (evt is SendPort) requestTilePort = evt; + requestTilePort.send(null); + evts++; + } + + tileIsolate.kill(priority: Isolate.immediate); + tilereceivePort.close(); + + return evts; + } + + group( + 'Rectangle Region', + () { + final rectRegion = RectangleRegion( + LatLngBounds(const LatLng(-1, -1), const LatLng(1, 1)), + ).toDownloadable(minZoom: 1, maxZoom: 16, options: TileLayer()); + + test( + 'Count By Counter', + () => expect(TileCounters.rectangleTiles(rectRegion), 179196), + ); + + test( + 'Counter Duration', + () => print( + '${List.generate( + 2000, + (index) { + final clock = Stopwatch()..start(); + TileCounters.rectangleTiles(rectRegion); + clock.stop(); + return clock.elapsedMilliseconds; + }, + growable: false, + ).average} ms', + ), + ); + + test( + 'Generator Duration & Count', + () async { + final clock = Stopwatch()..start(); + final tiles = await countByGenerator(rectRegion); + clock.stop(); + print('${clock.elapsedMilliseconds / 1000} s'); + expect(tiles, 179196); + }, + ); + }, + timeout: const Timeout(Duration(minutes: 1)), + ); + + group( + 'Ranged Region', + () { + test( + 'Start Offset Count', + () { + final region = RectangleRegion( + LatLngBounds(const LatLng(-1, -1), const LatLng(1, 1)), + ).toDownloadable( + minZoom: 1, + maxZoom: 16, + options: TileLayer(), + start: 10, + ); + expect(TileCounters.rectangleTiles(region), 179187); + }, + ); + + test( + 'End Offset Count', + () { + final region = RectangleRegion( + LatLngBounds(const LatLng(-1, -1), const LatLng(1, 1)), + ).toDownloadable( + minZoom: 1, + maxZoom: 16, + options: TileLayer(), + end: 100, + ); + expect(TileCounters.rectangleTiles(region), 100); + }, + ); + + test( + 'Start & End Offset Count', + () { + final region = RectangleRegion( + LatLngBounds(const LatLng(-1, -1), const LatLng(1, 1)), + ).toDownloadable( + minZoom: 1, + maxZoom: 16, + options: TileLayer(), + start: 10, + end: 100, + ); + expect(TileCounters.rectangleTiles(region), 91); + }, + ); + + test( + 'Start Offset Generate', + () async { + final region = RectangleRegion( + LatLngBounds(const LatLng(-1, -1), const LatLng(1, 1)), + ).toDownloadable( + minZoom: 1, + maxZoom: 16, + options: TileLayer(), + start: 10, + ); + expect(await countByGenerator(region), 179187); + }, + ); + + test( + 'End Offset Generate', + () async { + final region = RectangleRegion( + LatLngBounds(const LatLng(-1, -1), const LatLng(1, 1)), + ).toDownloadable( + minZoom: 1, + maxZoom: 16, + options: TileLayer(), + end: 100, + ); + expect(await countByGenerator(region), 100); + }, + ); + + test( + 'Start & End Offset Generate', + () async { + final region = RectangleRegion( + LatLngBounds(const LatLng(-1, -1), const LatLng(1, 1)), + ).toDownloadable( + minZoom: 1, + maxZoom: 16, + options: TileLayer(), + start: 10, + end: 100, + ); + expect(await countByGenerator(region), 91); + }, + ); + }, + ); + + group( + 'Circle Region', + () { + final circleRegion = const CircleRegion(LatLng(0, 0), 200) + .toDownloadable(minZoom: 1, maxZoom: 15, options: TileLayer()); + + test( + 'Count By Counter', + () => expect(TileCounters.circleTiles(circleRegion), 61564), + ); + + test( + 'Count By Generator', + () async => expect(await countByGenerator(circleRegion), 61564), + ); + + test( + 'Counter Duration', + () => print( + '${List.generate( + 500, + (index) { + final clock = Stopwatch()..start(); + TileCounters.circleTiles(circleRegion); + clock.stop(); + return clock.elapsedMilliseconds; + }, + growable: false, + ).average} ms', + ), + ); + + test( + 'Generator Duration', + () async { + final clock = Stopwatch()..start(); + await countByGenerator(circleRegion); + clock.stop(); + print('${clock.elapsedMilliseconds / 1000} s'); + }, + ); + }, + timeout: const Timeout(Duration(minutes: 1)), + ); + + group( + 'Line Region', + () { + final lineRegion = const LineRegion( + [LatLng(-1, -1), LatLng(1, 1), LatLng(1, -1)], + 5000, + ).toDownloadable(minZoom: 1, maxZoom: 15, options: TileLayer()); + + test( + 'Count By Counter', + () => expect(TileCounters.lineTiles(lineRegion), 5040), + ); + + test( + 'Count By Generator', + () async => expect(await countByGenerator(lineRegion), 5040), + ); + + test( + 'Counter Duration', + () => print( + '${List.generate( + 300, + (index) { + final clock = Stopwatch()..start(); + TileCounters.lineTiles(lineRegion); + clock.stop(); + return clock.elapsedMilliseconds; + }, + growable: false, + ).average} ms', + ), + ); + + test( + 'Generator Duration', + () async { + final clock = Stopwatch()..start(); + await countByGenerator(lineRegion); + clock.stop(); + print('${clock.elapsedMilliseconds / 1000} s'); + }, + ); + }, + timeout: const Timeout(Duration(minutes: 1)), + ); + + group( + 'Custom Polygon Region', + () { + final customPolygonRegion1 = const CustomPolygonRegion([ + LatLng(51.45818683312154, -0.9674646220840917), + LatLng(51.55859639937614, -0.9185366064186982), + LatLng(51.476641197796724, -0.7494743298246318), + LatLng(51.56029831737391, -0.5322770067805148), + LatLng(51.235701626195365, -0.5746290119276093), + LatLng(51.38781341753136, -0.6779891095601829), + ]).toDownloadable(minZoom: 1, maxZoom: 17, options: TileLayer()); + + final customPolygonRegion2 = const CustomPolygonRegion([ + LatLng(-1, -1), + LatLng(1, -1), + LatLng(1, 1), + LatLng(-1, 1), + ]).toDownloadable(minZoom: 1, maxZoom: 17, options: TileLayer()); + + test( + 'Count By Counter', + () => expect( + TileCounters.customPolygonTiles(customPolygonRegion1), + 15962, + ), + ); + + test( + 'Count By Generator', + () async => expect(await countByGenerator(customPolygonRegion1), 15962), + ); + + test( + 'Count By Counter (Compare to Rectangle Region)', + () => expect( + TileCounters.customPolygonTiles(customPolygonRegion2), + 712096, + ), + ); + + test( + 'Count By Generator (Compare to Rectangle Region)', + () async => + expect(await countByGenerator(customPolygonRegion2), 712096), + ); + + test( + 'Counter Duration', + () => print( + '${List.generate( + 500, + (index) { + final clock = Stopwatch()..start(); + TileCounters.customPolygonTiles(customPolygonRegion1); + clock.stop(); + return clock.elapsedMilliseconds; + }, + growable: false, + ).average} ms', + ), + ); + + test( + 'Generator Duration', + () async { + final clock = Stopwatch()..start(); + await countByGenerator(customPolygonRegion1); + clock.stop(); + print('${clock.elapsedMilliseconds / 1000} s'); + }, + ); + }, + 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/.gitignore b/tile_server/.gitignore new file mode 100644 index 00000000..fc374886 --- /dev/null +++ b/tile_server/.gitignore @@ -0,0 +1,2 @@ +bin/tile_server.exe +source/placeholders \ No newline at end of file diff --git a/tile_server/bin/generate_dart_images.dart b/tile_server/bin/generate_dart_images.dart new file mode 100644 index 00000000..0a5c8fde --- /dev/null +++ b/tile_server/bin/generate_dart_images.dart @@ -0,0 +1,42 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'dart:io'; + +import 'package:path/path.dart' as p; + +const staticFilesInfo = [ + (name: 'sea', extension: 'png'), + (name: 'land', extension: 'png'), + (name: 'favicon', extension: 'ico'), +]; + +void main(List _) { + final execPath = p.split(Platform.script.toFilePath()); + final staticPath = + p.joinAll([...execPath.getRange(0, execPath.length - 2), 'static']); + + Directory(p.join(staticPath, 'generated')).createSync(); + + for (final staticFile in staticFilesInfo) { + final dartFile = File( + p.join( + staticPath, + 'generated', + '${staticFile.name}.dart', + ), + ); + final imageFile = File( + p.join( + staticPath, + 'source', + 'images', + '${staticFile.name}.${staticFile.extension}', + ), + ); + + dartFile.writeAsStringSync( + 'final ${staticFile.name}TileBytes = ${imageFile.readAsBytesSync()};\n', + ); + } +} diff --git a/tile_server/bin/tile_server.dart b/tile_server/bin/tile_server.dart new file mode 100644 index 00000000..9e2beb94 --- /dev/null +++ b/tile_server/bin/tile_server.dart @@ -0,0 +1,153 @@ +// Copyright © Luka S (JaffaKetchup) under GPL-v3 +// A full license can be found at .\LICENSE + +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math'; + +import 'package:dart_console/dart_console.dart'; +import 'package:jaguar/jaguar.dart'; + +import '../static/generated/favicon.dart'; +import '../static/generated/land.dart'; +import '../static/generated/sea.dart'; + +Future main(List _) async { + // Initialise console + final console = Console() + ..hideCursor() + ..setTextStyle(bold: true, underscore: true) + ..write('\nFMTC Testing Tile Server\n') + ..setTextStyle() + ..write('© Luka S (JaffaKetchup)\n') + ..write( + "Miniature fake tile server designed to test FMTC's throughput and download speeds\n\n", + ); + + // Monitor requests per second measurement (tps) + final requestTimestamps = []; + var lastRate = 0; + Timer.periodic(const Duration(seconds: 1), (_) { + lastRate = requestTimestamps.length; + requestTimestamps.clear(); + }); + + // Setup artificial delay + const artificialDelayChangeAmount = Duration(milliseconds: 2); + Duration currentArtificialDelay = Duration.zero; + + // Track number of sea tiles served + int servedSeaTiles = 0; + + // Initialise HTTP server + final server = Jaguar( + multiThread: true, + onRouteServed: (ctx) { + 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', + ); + }, + port: 7070, + ); + + // Handle keyboard events + final keyboardHandlerreceivePort = ReceivePort(); + await Isolate.spawn( + (sendPort) { + while (true) { + final key = Console().readKey(); + + if (key.char.toLowerCase() == 'q') Isolate.exit(); + + if (key.controlChar == ControlCharacter.arrowUp) sendPort.send(1); + if (key.controlChar == ControlCharacter.arrowDown) sendPort.send(-1); + } + }, + keyboardHandlerreceivePort.sendPort, + onExit: keyboardHandlerreceivePort.sendPort, + ); + keyboardHandlerreceivePort.listen( + (message) => + // Control artificial delay + currentArtificialDelay += artificialDelayChangeAmount * message, + // Stop server and quit + onDone: () { + server.close(); + console + ..setTextStyle(bold: true) + ..write('\n\nKilled HTTP server\n') + ..setTextStyle() + ..showCursor(); + exit(0); + }, + ); + + // Preload responses + final faviconReponse = ByteResponse( + body: faviconTileBytes, + mimeType: 'image/vnd.microsoft.icon', + ); + final landTileResponse = ByteResponse( + body: landTileBytes, + mimeType: MimeTypes.png, + ); + final seaTileResponse = ByteResponse( + body: seaTileBytes, + mimeType: MimeTypes.png, + ); + + // Initialise random chance for sea/land tile (1:10) + final random = Random(); + + server + // Serve 'favicon.ico' + ..get('/favicon.ico', (_) => faviconReponse) + // Serve tiles to all other requests + ..get( + '/:z/:x/:y', + (ctx) async { + // Get tile request segments + final z = ctx.pathParams.getInt('z', -1)!; + final x = ctx.pathParams.getInt('x', -1)!; + final y = ctx.pathParams.getInt('y', -1)!; + + // Check if tile request is inside valid range + if (x < 0 || y < 0 || z < 0) { + return Response(statusCode: 400); + } + final maxTileNum = sqrt(pow(4, z)) - 1; + if (x > maxTileNum || y > maxTileNum) { + return Response(statusCode: 400); + } + + // Create artificial delay if applicable + if (currentArtificialDelay > Duration.zero) { + await Future.delayed(currentArtificialDelay); + } + + // Serve either sea or land tile + if (ctx.path == '/17/0/0' || random.nextInt(10) == 0) { + servedSeaTiles += 1; + return seaTileResponse; + } + return landTileResponse; + }, + ); + + // Output basic console instructions + console + ..setTextStyle(italic: true) + ..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', + ) + ..setTextStyle() + ..write('----------\n'); + + // Start HTTP server + await server.serve(logRequests: true); +} diff --git a/tile_server/pubspec.yaml b/tile_server/pubspec.yaml new file mode 100644 index 00000000..b1bec82d --- /dev/null +++ b/tile_server/pubspec.yaml @@ -0,0 +1,11 @@ +name: tile_server +description: Miniature tile server designed to test FMTC's throughput and download speeds. +version: 1.0.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + dart_console: ^1.2.0 + jaguar: ^3.1.3 + path: ^1.9.0 diff --git a/tile_server/start_dev.bat b/tile_server/start_dev.bat new file mode 100644 index 00000000..0d81c0ca --- /dev/null +++ b/tile_server/start_dev.bat @@ -0,0 +1,2 @@ +@echo off +start cmd /C "dart compile exe bin/tile_server.dart && start /b bin/tile_server.exe" \ No newline at end of file diff --git a/tile_server/static/generated/favicon.dart b/tile_server/static/generated/favicon.dart new file mode 100644 index 00000000..7dd43c1b --- /dev/null +++ b/tile_server/static/generated/favicon.dart @@ -0,0 +1,2 @@ +// Will be replaced automatically by GitHub Actions +final faviconTileBytes = []; diff --git a/tile_server/static/generated/land.dart b/tile_server/static/generated/land.dart new file mode 100644 index 00000000..1029ac5f --- /dev/null +++ b/tile_server/static/generated/land.dart @@ -0,0 +1,2 @@ +// Will be replaced automatically by GitHub Actions +final landTileBytes = []; diff --git a/tile_server/static/generated/sea.dart b/tile_server/static/generated/sea.dart new file mode 100644 index 00000000..196bd5af --- /dev/null +++ b/tile_server/static/generated/sea.dart @@ -0,0 +1,2 @@ +// Will be replaced automatically by GitHub Actions +final seaTileBytes = []; diff --git a/tile_server/static/source/images/favicon.ico b/tile_server/static/source/images/favicon.ico new file mode 100644 index 00000000..4620957b Binary files /dev/null and b/tile_server/static/source/images/favicon.ico differ diff --git a/tile_server/static/source/images/land.png b/tile_server/static/source/images/land.png new file mode 100644 index 00000000..6239c7a2 Binary files /dev/null and b/tile_server/static/source/images/land.png differ diff --git a/tile_server/static/source/images/sea.png b/tile_server/static/source/images/sea.png new file mode 100644 index 00000000..f607ae0a Binary files /dev/null and b/tile_server/static/source/images/sea.png differ diff --git a/tile_server/static/source/placeholders/favicon.dart b/tile_server/static/source/placeholders/favicon.dart new file mode 100644 index 00000000..7dd43c1b --- /dev/null +++ b/tile_server/static/source/placeholders/favicon.dart @@ -0,0 +1,2 @@ +// Will be replaced automatically by GitHub Actions +final faviconTileBytes = []; diff --git a/tile_server/static/source/placeholders/land.dart b/tile_server/static/source/placeholders/land.dart new file mode 100644 index 00000000..1029ac5f --- /dev/null +++ b/tile_server/static/source/placeholders/land.dart @@ -0,0 +1,2 @@ +// Will be replaced automatically by GitHub Actions +final landTileBytes = []; diff --git a/tile_server/static/source/placeholders/sea.dart b/tile_server/static/source/placeholders/sea.dart new file mode 100644 index 00000000..196bd5af --- /dev/null +++ b/tile_server/static/source/placeholders/sea.dart @@ -0,0 +1,2 @@ +// Will be replaced automatically by GitHub Actions +final seaTileBytes = []; diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index 9f1ce59a..a2b52f32 100644 --- a/windowsApplicationInstallerSetup.iss +++ b/windowsApplicationInstallerSetup.iss @@ -1,7 +1,7 @@ ; Script generated by the Inno Setup Script Wizard #define MyAppName "FMTC Demo" -#define MyAppVersion "for 8.0.0" +#define MyAppVersion "for 9.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" @@ -26,7 +26,7 @@ DisableWelcomePage=no LicenseFile=LICENSE PrivilegesRequired=lowest PrivilegesRequiredOverridesAllowed=dialog -OutputDir=prebuiltExampleApplications +OutputDir=windowsTemp OutputBaseFilename=WindowsApplication SetupIconFile=example\assets\icons\ProjectIcon.ico Compression=lzma @@ -65,14 +65,11 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ ; Specify all files within 'build/windows/runner/Release' except 'example.exe' [Files] -Source: "example\build\windows\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion -Source: "example\build\windows\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "example\build\windows\runner\Release\isar_flutter_libs_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "example\build\windows\runner\Release\isar.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "example\build\windows\runner\Release\permission_handler_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "example\build\windows\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "example\build\windows\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "example\build\windows\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs +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\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs [Registry] Root: HKA; Subkey: "Software\Classes\{#MyAppAssocExt}\OpenWithProgids"; ValueType: string; ValueName: "{#MyAppAssocKey}"; ValueData: ""; Flags: uninsdeletevalue