diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml
deleted file mode 100644
index 65a35b565..000000000
--- a/.idea/libraries/Dart_Packages.xml
+++ /dev/null
@@ -1,628 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index 08c8909ee..e182bb258 100644
--- a/README.md
+++ b/README.md
@@ -199,9 +199,16 @@ Widget build(ctx) {
```
Make sure PanBoundaries are within offline map boundary to stop missing asset errors.
-See the `flutter_map_example/` folder for a working example.
-Note that there is also `FileTileProvider()`, which you can use to load tiles from the filesystem.
+
+Note that there is also next classes for offline tiles:
+* `FileTileProvider`, which you can use to load tiles from the filesystem;
+* `StorageCachingTileProvider`, caches all browsing tiles in local db;
+* `TileStorageCachingManager`, manages local tile db. This class
+has easy static api for config db size, loading and preloading tiles.
+`StorageCachingTileProvider` uses this class under the hood.
+
+See the `flutter_map_example/` folder for a working examples.
## Plugins
diff --git a/example/lib/main.dart b/example/lib/main.dart
index aeb609bd2..b7641ffa8 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import './pages/animated_map_controller.dart';
+import './pages/auto_cached_tiles.dart';
import './pages/circle.dart';
import './pages/custom_crs/custom_crs.dart';
import './pages/esri.dart';
@@ -50,6 +51,7 @@ class MyApp extends StatelessWidget {
OverlayImagePage.route: (context) => OverlayImagePage(),
WMSLayerPage.route: (context) => WMSLayerPage(),
CustomCrsPage.route: (context) => CustomCrsPage(),
+ AutoCachedTilesPage.route: (context) => AutoCachedTilesPage(),
},
);
}
diff --git a/example/lib/pages/auto_cached_tiles.dart b/example/lib/pages/auto_cached_tiles.dart
new file mode 100644
index 000000000..24bc7b58b
--- /dev/null
+++ b/example/lib/pages/auto_cached_tiles.dart
@@ -0,0 +1,508 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_map/flutter_map.dart';
+import 'package:latlong/latlong.dart';
+import 'package:tuple/tuple.dart';
+
+import '../widgets/drawer.dart';
+
+class AutoCachedTilesPage extends StatelessWidget {
+ static const String route = '/auto_cached_tiles';
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: Text('AutoCachedTiles Map')),
+ drawer: buildDrawer(context, route),
+ body: _AutoCachedTilesPageContent());
+ }
+}
+
+class _AutoCachedTilesPageContent extends StatefulWidget {
+ @override
+ _AutoCachedTilesPageContentState createState() =>
+ _AutoCachedTilesPageContentState();
+}
+
+class _AutoCachedTilesPageContentState
+ extends State<_AutoCachedTilesPageContent> {
+ final northController = TextEditingController();
+ final eastController = TextEditingController();
+ final westController = TextEditingController();
+ final southController = TextEditingController();
+ final minZoomController = TextEditingController();
+ final maxZoomController = TextEditingController();
+
+ final mapController = MapController();
+
+ LatLngBounds _selectedBounds;
+
+ final decimalInputFormatter =
+ WhitelistingTextInputFormatter(RegExp(r'^-?\d{0,3}\.?\d{0,6}$'));
+
+ @override
+ void initState() {
+ super.initState();
+ northController.addListener(_handleBoundsInput);
+ eastController.addListener(_handleBoundsInput);
+ westController.addListener(_handleBoundsInput);
+ southController.addListener(_handleBoundsInput);
+ }
+
+ @override
+ void dispose() {
+ northController.dispose();
+ eastController.dispose();
+ westController.dispose();
+ southController.dispose();
+ minZoomController.dispose();
+ maxZoomController.dispose();
+ super.dispose();
+ }
+
+ void _handleBoundsInput() {
+ final north =
+ double.tryParse(northController.text) ?? _selectedBounds?.north;
+ final east = double.tryParse(eastController.text) ?? _selectedBounds?.east;
+ final west = double.tryParse(westController.text) ?? _selectedBounds?.west;
+ final south =
+ double.tryParse(southController.text) ?? _selectedBounds?.south;
+ if (north == null || east == null || west == null || south == null) {
+ return;
+ }
+ final sw = LatLng(south, west);
+ final ne = LatLng(north, east);
+ final bounds = LatLngBounds(sw, ne);
+ if (!bounds.isValid) return;
+ setState(() => _selectedBounds = bounds);
+ }
+
+ void _showErrorSnack(String errorMessage) async {
+ SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
+ Scaffold.of(context).showSnackBar(SnackBar(
+ content: Text(errorMessage),
+ ));
+ });
+ }
+
+ void _calculateApproxTileAmount() {
+ if (!_checkTileLoadParams()) return;
+ final zoomMin = int.tryParse(minZoomController.text);
+ final zoomMax = int.tryParse(maxZoomController.text) ?? zoomMin;
+ final approximateTileCount =
+ StorageCachingTileProvider.approximateTileAmount(
+ bounds: _selectedBounds, minZoom: zoomMin, maxZoom: zoomMax);
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Text('Aproximate tile amount'),
+ content: Text(
+ '~ $approximateTileCount',
+ style: Theme.of(ctx).textTheme.display1,
+ ),
+ actions: [
+ FlatButton(
+ onPressed: () => Navigator.of(ctx).pop(),
+ child: Text('Ok'),
+ )
+ ],
+ ));
+ }
+
+ void _changeSettings() async {
+ final currentMaxTileAmount =
+ await TileStorageCachingManager.maxCachedTilesAmount;
+ final result = await showDialog(
+ context: context,
+ builder: (ctx) {
+ final tileAmountController = TextEditingController();
+ tileAmountController.text = currentMaxTileAmount.toString();
+ return AlertDialog(
+ title: Text('Change max caching tile amount'),
+ actions: [
+ FlatButton(
+ child: Text('Cancel'),
+ onPressed: () => Navigator.of(ctx).pop(),
+ ),
+ FlatButton(
+ child: Text('Ok'),
+ onPressed: () => Navigator.of(ctx)
+ .pop(int.tryParse(tileAmountController.text ?? '')),
+ )
+ ],
+ content: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text('max cach tile amount: '),
+ SizedBox(
+ width: 8,
+ ),
+ Expanded(
+ // width: width / 3,
+ child: TextField(
+ inputFormatters: [
+ WhitelistingTextInputFormatter.digitsOnly
+ ],
+ keyboardType: TextInputType.number,
+ controller: tileAmountController,
+ ),
+ )
+ ],
+ ),
+ );
+ });
+ if (result == null || result == currentMaxTileAmount) return;
+ await TileStorageCachingManager.changeMaxTileAmount(result);
+ }
+
+ bool _checkTileLoadParams() {
+ final zoomMin = int.tryParse(minZoomController.text);
+ final zoomMax = int.tryParse(maxZoomController.text) ?? zoomMin;
+ if (zoomMin == null) {
+ _showErrorSnack('At least zoomMin must be defined!');
+ return false;
+ }
+ if (zoomMin < 0 || zoomMin > 19) {
+ _showErrorSnack('valid zoom value must be inside 1..19 range');
+ return false;
+ }
+ if (zoomMax < zoomMin) {
+ _showErrorSnack('Max zoom must be bigger than min zoom');
+ return false;
+ }
+ if (_selectedBounds == null) {
+ _showErrorSnack('bounds of caching area are not defined');
+ return false;
+ }
+ return true;
+ }
+
+ Future _loadMap(
+ StorageCachingTileProvider tileProvider, TileLayerOptions options) async {
+ _hideKeyboard();
+ if (!_checkTileLoadParams()) return;
+ final zoomMin = int.tryParse(minZoomController.text);
+ final zoomMax = int.tryParse(maxZoomController.text) ?? zoomMin;
+ final approximateTileCount =
+ StorageCachingTileProvider.approximateTileAmount(
+ bounds: _selectedBounds, minZoom: zoomMin, maxZoom: zoomMax);
+ final maxTilesAmount = await TileStorageCachingManager.maxCachedTilesAmount;
+ if (approximateTileCount > maxTilesAmount) {
+ _showErrorSnack(
+ 'tiles ammount $approximateTileCount bigger than current maximum $maxTilesAmount');
+ return;
+ }
+ await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Text('Tile loading...'),
+ content: StreamBuilder>(
+ initialData: Tuple3(0, 0, 0),
+ stream: tileProvider.loadTiles(
+ _selectedBounds, zoomMin, zoomMax, options),
+ builder: (ctx, snapshot) {
+ if (snapshot.hasError) {
+ return Text('error: ${snapshot.error.toString()}');
+ }
+ if (snapshot.connectionState == ConnectionState.done) {
+ Navigator.of(ctx).pop();
+ }
+ final tileIndex = snapshot.data?.item1 ?? 0;
+ final tilesAmount = snapshot.data?.item3 ?? 0;
+ return getLoadProgresWidget(ctx, tileIndex, tilesAmount);
+ },
+ ),
+ actions: [
+ FlatButton(
+ child: Text('Cancel'),
+ onPressed: () => Navigator.of(ctx).pop(),
+ )
+ ]));
+ }
+
+ Future _deleteCachedMap() async {
+ _hideKeyboard();
+ final currentCacheSize =
+ await TileStorageCachingManager.cacheDbSize / 1024 / 1024;
+ final currentCacheAmount =
+ await TileStorageCachingManager.cachedTilesAmount;
+ final result = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Text('Cache cleaning'),
+ content: Text(
+ 'Cache db size: ${currentCacheSize.toStringAsFixed(2)} mb.'
+ '\nCached tiles amount: $currentCacheAmount'
+ '\nSeriosly want to delete this stuf?'),
+ actions: [
+ FlatButton(
+ child: Text('Cancel'),
+ onPressed: () => Navigator.pop(context, false),
+ ),
+ FlatButton(
+ child: Text('OK'),
+ onPressed: () => Navigator.pop(context, true),
+ )
+ ],
+ ));
+ if (result == true) {
+ await TileStorageCachingManager.cleanCache();
+ _showErrorSnack('cache cleanded ...');
+ }
+ }
+
+ void _hideKeyboard() => FocusScope.of(context).requestFocus(FocusNode());
+
+ void _focusToBounds() {
+ _hideKeyboard();
+ mapController.fitBounds(_selectedBounds,
+ options: FitBoundsOptions(padding: EdgeInsets.all(32)));
+ }
+
+ Widget getBoundsInputWidget(BuildContext context) {
+ final size = MediaQuery.of(context).size;
+ final boundsSectionWidth = size.width * 0.8;
+ final zoomSectionWidth = size.width - boundsSectionWidth;
+ final boundsInputSize = boundsSectionWidth / 2 - 4 * 16;
+ final zoomInputWidth = zoomSectionWidth - 32;
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0),
+ child: Row(
+ children: [
+ //BOUNDS
+ Expanded(
+ child: Container(
+ padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
+ decoration: BoxDecoration(
+ border: Border.all(color: Colors.grey, width: 2),
+ borderRadius: BorderRadius.all(Radius.circular(10)),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text('BOUNDS', style: Theme.of(context).textTheme.subhead),
+ SizedBox(
+ width: boundsInputSize,
+ child: TextField(
+ textAlign: TextAlign.center,
+ decoration: InputDecoration(hintText: 'north'),
+ inputFormatters: [decimalInputFormatter],
+ keyboardType:
+ TextInputType.numberWithOptions(decimal: true),
+ controller: northController,
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ SizedBox(
+ width: boundsInputSize,
+ child: TextField(
+ textAlign: TextAlign.center,
+ decoration: InputDecoration(hintText: 'west'),
+ inputFormatters: [decimalInputFormatter],
+ keyboardType:
+ TextInputType.numberWithOptions(decimal: true),
+ controller: westController,
+ ),
+ ),
+ SizedBox(
+ width: boundsInputSize,
+ child: TextField(
+ textAlign: TextAlign.center,
+ decoration: InputDecoration(hintText: 'east'),
+ inputFormatters: [decimalInputFormatter],
+ keyboardType:
+ TextInputType.numberWithOptions(decimal: true),
+ controller: eastController,
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(
+ width: boundsInputSize,
+ child: TextField(
+ textAlign: TextAlign.center,
+ decoration: InputDecoration(hintText: 'south'),
+ inputFormatters: [decimalInputFormatter],
+ keyboardType:
+ TextInputType.numberWithOptions(decimal: true),
+ controller: southController,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ SizedBox(
+ width: 16,
+ ),
+ //ZOOM
+ Container(
+ padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
+ decoration: BoxDecoration(
+ border: Border.all(color: Colors.grey, width: 2),
+ borderRadius: BorderRadius.all(Radius.circular(10))),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text('ZOOM', style: Theme.of(context).textTheme.subhead),
+ SizedBox(
+ width: zoomInputWidth,
+ child: TextField(
+ textAlign: TextAlign.center,
+ maxLength: 2,
+ decoration:
+ InputDecoration(counterText: '', hintText: 'min'),
+ inputFormatters: [
+ WhitelistingTextInputFormatter.digitsOnly
+ ],
+ keyboardType:
+ TextInputType.numberWithOptions(decimal: false),
+ controller: minZoomController,
+ ),
+ ),
+ SizedBox(
+ width: zoomInputWidth,
+ child: TextField(
+ textAlign: TextAlign.center,
+ decoration: InputDecoration(
+ counterText: '',
+ hintText: 'max',
+ ),
+ maxLength: 2,
+ inputFormatters: [
+ WhitelistingTextInputFormatter.digitsOnly
+ ],
+ keyboardType:
+ TextInputType.numberWithOptions(decimal: false),
+ controller: maxZoomController,
+ ),
+ )
+ ],
+ ),
+ )
+ ],
+ ),
+ );
+ }
+
+ Widget getLoadProgresWidget(
+ BuildContext context, int tileIndex, int tileAmount) {
+ if (tileAmount == 0) {
+ tileAmount = 1;
+ }
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox(
+ width: 50,
+ height: 50,
+ child: Stack(
+ children: [
+ SizedBox(
+ width: 50,
+ height: 50,
+ child: CircularProgressIndicator(
+ backgroundColor: Colors.grey,
+ value: tileIndex / tileAmount,
+ ),
+ ),
+ Align(
+ alignment: Alignment.center,
+ child: Text(
+ (tileIndex / tileAmount * 100).toInt().toString(),
+ style: Theme.of(context).textTheme.subhead,
+ ),
+ )
+ ],
+ ),
+ ),
+ SizedBox(
+ height: 8,
+ ),
+ Text('$tileIndex/$tileAmount',
+ style: Theme.of(context).textTheme.subtitle)
+ ],
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final tileProvider = StorageCachingTileProvider();
+ final tileLayerOptions = TileLayerOptions(
+ tileProvider: tileProvider,
+ urlTemplate: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ subdomains: ['a', 'b', 'c'],
+ );
+ return Column(
+ children: [
+ Expanded(
+ child: FlutterMap(
+ mapController: mapController,
+ options: MapOptions(
+ center: LatLng(55.753215, 37.622504),
+ maxZoom: 18.0,
+ zoom: 13.0,
+ ),
+ layers: [
+ tileLayerOptions,
+ PolygonLayerOptions(
+ polygons: _selectedBounds == null
+ ? []
+ : [
+ Polygon(
+ color: Colors.red.withAlpha(128),
+ borderColor: Colors.red,
+ borderStrokeWidth: 3,
+ points: [
+ _selectedBounds.southWest,
+ _selectedBounds.southEast,
+ _selectedBounds.northEast,
+ _selectedBounds.northWest
+ ])
+ ]),
+ ],
+ ),
+ ),
+ Padding(
+ padding: EdgeInsets.only(top: 8.0, bottom: 8.0),
+ child: Text('define area borders and zoom edges for tile caching'),
+ ),
+ getBoundsInputWidget(context),
+ Container(
+ height: 56,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ IconButton(
+ icon: Icon(Icons.settings),
+ onPressed: _changeSettings,
+ ),
+ IconButton(
+ icon: Icon(Icons.delete),
+ onPressed: _deleteCachedMap,
+ ),
+ IconButton(
+ icon: Icon(Icons.cloud_download),
+ onPressed: () => _loadMap(tileProvider, tileLayerOptions),
+ ),
+ IconButton(
+ icon: Icon(Icons.straighten),
+ onPressed: _calculateApproxTileAmount,
+ ),
+ IconButton(
+ icon: Icon(Icons.filter_center_focus),
+ onPressed: _selectedBounds == null ? null : _focusToBounds,
+ )
+ ],
+ ))
+ ],
+ );
+ }
+}
diff --git a/example/lib/widgets/drawer.dart b/example/lib/widgets/drawer.dart
index 8e8a0fe52..11866db5d 100644
--- a/example/lib/widgets/drawer.dart
+++ b/example/lib/widgets/drawer.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../pages/animated_map_controller.dart';
+import '../pages/auto_cached_tiles.dart';
import '../pages/circle.dart';
import '../pages/custom_crs/custom_crs.dart';
import '../pages/esri.dart';
@@ -35,6 +36,12 @@ Drawer buildDrawer(BuildContext context, String currentRoute) {
Navigator.pushReplacementNamed(context, HomePage.route);
},
),
+ ListTile(
+ title: const Text('AutoCachedTiles'),
+ selected: currentRoute == AutoCachedTilesPage.route,
+ onTap: () => Navigator.pushReplacementNamed(
+ context, AutoCachedTilesPage.route),
+ ),
ListTile(
title: const Text('WMS Layer'),
selected: currentRoute == WMSLayerPage.route,
diff --git a/lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_tile_provider.dart b/lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_tile_provider.dart
new file mode 100644
index 000000000..4e8ec220e
--- /dev/null
+++ b/lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_tile_provider.dart
@@ -0,0 +1,168 @@
+import 'dart:ui';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/painting.dart';
+import 'package:flutter_map/flutter_map.dart';
+import 'package:flutter_map/src/layer/tile_layer.dart';
+import 'package:flutter_map/src/layer/tile_provider/storage_caching_tile_provider/tile_storage_caching_manager.dart';
+import 'package:flutter_map/src/layer/tile_provider/tile_provider.dart';
+import 'package:http/http.dart' as http;
+import 'package:tuple/tuple.dart';
+export 'package:flutter_map/src/layer/tile_provider/storage_caching_tile_provider/tile_storage_caching_manager.dart';
+
+///Provider that persist loaded raster tiles inside local sqlite db
+/// [cachedValidDuration] - valid time period since [DateTime.now]
+/// which determines the need for a request for remote tile server. Default value
+/// is one day, that means - all cached tiles today and day before don't need rewriting.
+class StorageCachingTileProvider extends TileProvider {
+ static final kMaxPreloadTileAreaCount = 3000;
+ final Duration cachedValidDuration;
+
+ StorageCachingTileProvider(
+ {this.cachedValidDuration = const Duration(days: 1)});
+
+ @override
+ ImageProvider getImage(Coords coords, TileLayerOptions options) {
+ final tileUrl = getTileUrl(coords, options);
+ return CachedTileImageProvider(tileUrl,
+ Coords(coords.x.toInt(), coords.y.toInt())..z = coords.z.toInt());
+ }
+
+ /// Caching tile area by provided [bounds], zoom edges and [options].
+ /// The maximum number of tiles to load is [kMaxPreloadTileAreaCount].
+ /// To check tiles number before calling this method, use
+ /// [approximateTileAmount].
+ /// Return [Tuple3] with uploaded tile index as [Tuple3.item1],
+ /// errors count as [Tuple3.item2], and total tiles count need to be downloaded
+ /// as [Tuple3.item3]
+ Stream> loadTiles(
+ LatLngBounds bounds, int minZoom, int maxZoom, TileLayerOptions options,
+ {Function(dynamic) errorHandler}) async* {
+ final tilesRange = approximateTileRange(
+ bounds: bounds,
+ minZoom: minZoom,
+ maxZoom: maxZoom,
+ tileSize: CustomPoint(options.tileSize, options.tileSize));
+ assert(tilesRange.length <= kMaxPreloadTileAreaCount,
+ '${tilesRange.length} to many tiles for caching');
+ var errorsCount = 0;
+ for (var i = 0; i < tilesRange.length; i++) {
+ try {
+ final cord = tilesRange[i];
+ final url = getTileUrl(cord, options);
+ // get network tile
+ final bytes = (await http.get(url)).bodyBytes;
+ // save tile to cache
+ await TileStorageCachingManager.saveTile(bytes, cord);
+ } catch (e) {
+ errorsCount++;
+ if (errorHandler != null) errorHandler(e);
+ }
+ yield Tuple3(i + 1, errorsCount, tilesRange.length);
+ }
+ }
+
+ ///Get approximate tile amount from bounds and zoom edges.
+ ///[crs] and [tileSize] is optional.
+ static int approximateTileAmount(
+ {@required LatLngBounds bounds,
+ @required int minZoom,
+ @required int maxZoom,
+ Crs crs = const Epsg3857(),
+ tileSize = const CustomPoint(256, 256)}) {
+ assert(minZoom <= maxZoom, 'minZoom > maxZoom');
+ var amount = 0;
+ for (var zoomLevel in List.generate(
+ maxZoom - minZoom + 1, (index) => index + minZoom)) {
+ final nwPoint = crs
+ .latLngToPoint(bounds.northWest, zoomLevel.toDouble())
+ .unscaleBy(tileSize)
+ .floor();
+ final sePoint = crs
+ .latLngToPoint(bounds.southEast, zoomLevel.toDouble())
+ .unscaleBy(tileSize)
+ .ceil() -
+ CustomPoint(1, 1);
+ final a = sePoint.x - nwPoint.x + 1;
+ final b = sePoint.y - nwPoint.y + 1;
+ amount += a * b;
+ }
+ return amount;
+ }
+
+ ///Get tileRange from bounds and zoom edges.
+ ///[crs] and [tileSize] is optional.
+ static List approximateTileRange(
+ {@required LatLngBounds bounds,
+ @required int minZoom,
+ @required int maxZoom,
+ Crs crs = const Epsg3857(),
+ tileSize = const CustomPoint(256, 256)}) {
+ assert(minZoom <= maxZoom, 'minZoom > maxZoom');
+ final cords = [];
+ for (var zoomLevel in List.generate(
+ maxZoom - minZoom + 1, (index) => index + minZoom)) {
+ final nwPoint = crs
+ .latLngToPoint(bounds.northWest, zoomLevel.toDouble())
+ .unscaleBy(tileSize)
+ .floor();
+ final sePoint = crs
+ .latLngToPoint(bounds.southEast, zoomLevel.toDouble())
+ .unscaleBy(tileSize)
+ .ceil() -
+ CustomPoint(1, 1);
+ for (var x = nwPoint.x; x <= sePoint.x; x++) {
+ for (var y = nwPoint.y; y <= sePoint.y; y++) {
+ cords.add(Coords(x, y)..z = zoomLevel);
+ }
+ }
+ }
+ return cords;
+ }
+}
+
+class CachedTileImageProvider extends ImageProvider> {
+ final Function(dynamic) netWorkErrorHandler;
+ final String url;
+ final Coords coords;
+ final Duration cacheValidDuration;
+
+ CachedTileImageProvider(this.url, this.coords,
+ {this.cacheValidDuration = const Duration(days: 1),
+ this.netWorkErrorHandler});
+
+ @override
+ ImageStreamCompleter load(Coords key, decode) =>
+ MultiFrameImageStreamCompleter(
+ codec: _loadAsync(),
+ scale: 1,
+ informationCollector: () sync* {
+ yield DiagnosticsProperty('Image provider', this);
+ yield DiagnosticsProperty('Image key', key);
+ });
+
+ @override
+ Future> obtainKey(ImageConfiguration configuration) =>
+ SynchronousFuture(coords);
+
+ Future _loadAsync() async {
+ final localBytes = await TileStorageCachingManager.getTile(coords);
+ var bytes = localBytes?.item1;
+ if ((DateTime.now().millisecondsSinceEpoch -
+ (localBytes?.item2?.millisecondsSinceEpoch ?? 0)) >
+ cacheValidDuration.inMilliseconds) {
+ try {
+ // get network tile
+ bytes = (await http.get(url)).bodyBytes;
+ // save tile to cache
+ await TileStorageCachingManager.saveTile(bytes, coords);
+ } catch (e) {
+ if (netWorkErrorHandler != null) netWorkErrorHandler(e);
+ }
+ }
+ if (bytes == null) {
+ return Future.error('Failed to load tile for coords: $coords');
+ }
+ return await PaintingBinding.instance.instantiateImageCodec(bytes);
+ }
+}
diff --git a/lib/src/layer/tile_provider/storage_caching_tile_provider/tile_storage_caching_manager.dart b/lib/src/layer/tile_provider/storage_caching_tile_provider/tile_storage_caching_manager.dart
new file mode 100644
index 000000000..e3871b128
--- /dev/null
+++ b/lib/src/layer/tile_provider/storage_caching_tile_provider/tile_storage_caching_manager.dart
@@ -0,0 +1,239 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:flutter_map/flutter_map.dart';
+import 'package:path/path.dart';
+import 'package:sqflite/sqflite.dart';
+import 'package:synchronized/synchronized.dart';
+import 'package:tuple/tuple.dart';
+
+/// Singleton for managing tile sqlite db.
+class TileStorageCachingManager {
+ static TileStorageCachingManager _instance;
+
+ /// default value of maximum number of persisted tiles,
+ /// and average tile size ~ 0.017 mb -> so default cache size ~ 51 mb
+ static const int kDefaultMaxTileAmount = 3000;
+ static final kMaxRefreshRowsCount = 10;
+ static final String _kDbName = 'tile_cach.db';
+ static final String _kTilesTable = 'tiles';
+ static final String _kZoomLevelColumn = 'zoom_level';
+ static final String _kTileRowColumn = 'tile_row';
+ static final String _kTileColumnColumn = 'tile_column';
+ static final String _kTileDataColumn = 'tile_data';
+ static final String _kUpdateDateColumn = '_lastUpdateColumn';
+ static final String _kSizeTriggerName = 'size_trigger';
+
+ static final String _kTileCacheConfigTable = 'config';
+ static final String _kConfigKeyColumn = 'config_key';
+ static final String _kConfigValueColumn = 'config_value';
+ static final String _kMaxTileAmountConfig = 'max_tiles_amount_config';
+ Database _db;
+
+ final _lock = Lock();
+
+ static TileStorageCachingManager _getInstance() {
+ _instance ??= TileStorageCachingManager._internal();
+ return _instance;
+ }
+
+ factory TileStorageCachingManager() => _getInstance();
+
+ TileStorageCachingManager._internal();
+
+ Future get database async {
+ if (_db == null) {
+ await _lock.synchronized(() async {
+ if (_db == null) {
+ final path = await _path;
+ _db = await openDatabase(
+ path,
+ version: 1,
+ onConfigure: _onConfigure,
+ onCreate: _onCreate,
+ onUpgrade: _onUpgrade,
+ );
+ }
+ });
+ }
+ return _db;
+ }
+
+ static Future get _path async {
+ final databasePath = await getDatabasesPath();
+ final path = join(databasePath, _kDbName);
+ await Directory(databasePath).create(recursive: true);
+ return path;
+ }
+
+ static String _getSizeTriggerQuery(int tileCount) => '''
+ CREATE TRIGGER $_kSizeTriggerName
+ AFTER INSERT on $_kTilesTable
+ WHEN (select count(*) from $_kTilesTable) > $tileCount
+ BEGIN
+ DELETE from $_kTilesTable where $_kUpdateDateColumn <=
+ (select $_kUpdateDateColumn from $_kTilesTable
+ order by $_kUpdateDateColumn asc
+ LIMIT 1 OFFSET $kMaxRefreshRowsCount);
+ END;
+ ''';
+
+ Future _onConfigure(Database db) async {}
+
+ Future _onCreate(Database db, int version) async {
+ await _createConfigTable(db);
+ await _createCacheTable(db);
+ }
+
+ Future _onUpgrade(Database db, int oldVersion, int newVersion) async {}
+
+ Future _createConfigTable(Database db) async {
+ final batch = db.batch();
+ batch.execute('DROP TABLE IF EXISTS $_kTileCacheConfigTable');
+ batch.execute('''
+ CREATE TABLE $_kTileCacheConfigTable(
+ $_kConfigKeyColumn TEXT NOT NULL,
+ $_kConfigValueColumn TEXT NOT NULL
+ )
+ ''');
+ batch.execute('''
+ CREATE UNIQUE INDEX idx_config_key
+ ON $_kTileCacheConfigTable($_kConfigKeyColumn);
+ ''');
+ await batch.commit();
+ }
+
+ static Future _createCacheTable(Database db,
+ {int maxTileAmount = kDefaultMaxTileAmount}) async {
+ final batch = db.batch();
+ batch.execute('DROP TABLE IF EXISTS $_kTilesTable');
+ batch.execute('''
+ CREATE TABLE $_kTilesTable(
+ $_kZoomLevelColumn INTEGER NOT NULL,
+ $_kTileColumnColumn INTEGER NOT NULL,
+ $_kTileRowColumn INTEGER NOT NULL,
+ $_kTileDataColumn BLOB NOT NULL,
+ $_kUpdateDateColumn INTEGER NOT NULL
+ )
+ ''');
+ batch.execute('''
+ CREATE UNIQUE INDEX tile_index ON $_kTilesTable (
+ $_kZoomLevelColumn,
+ $_kTileColumnColumn,
+ $_kTileRowColumn
+ )
+ ''');
+ batch.execute(_getSizeTriggerQuery(maxTileAmount));
+ await batch.commit();
+ }
+
+ /// Get local tile by tile index [Coords].
+ /// Return [Tuple2], where [Tuple2.item1] is bytes of tile image,
+ /// [Tuple2.item2] - last update [DateTime] of this tile.
+ static Future> getTile(Coords coords,
+ {Duration valid}) async {
+ List