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 result = await (await _getInstance().database) + .rawQuery('select $_kTileDataColumn, $_kUpdateDateColumn from tiles ' + 'where $_kZoomLevelColumn = ${coords.z} AND ' + '$_kTileColumnColumn = ${coords.x} AND ' + '$_kTileRowColumn = ${coords.y} limit 1'); + return result.isNotEmpty + ? Tuple2( + result.first[_kTileDataColumn], + DateTime.fromMillisecondsSinceEpoch( + 1000 * result.first[_kUpdateDateColumn])) + : null; + } + + /// Save tile bytes [tile] with [cords] to local database. + /// Also saves update timestamp [DateTime.now]. + static Future saveTile(Uint8List tile, Coords cords) async { + await (await _getInstance().database).insert( + _kTilesTable, + { + _kZoomLevelColumn: cords.z, + _kTileColumnColumn: cords.x, + _kTileRowColumn: cords.y, + _kUpdateDateColumn: (DateTime.now().millisecondsSinceEpoch ~/ 1000), + _kTileDataColumn: tile + }, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + /// [maxTileAmount] - maximum number of persisted tiles, default value is 3000, + /// and average tile size ~ 0.017 mb -> so default cache size ~ 51 mb. + /// To avoid collisions this method should be called before widget build. + static Future changeMaxTileAmount(int maxTileAmount) async { + assert(maxTileAmount > 0, 'maxTileAmount must be bigger then 0'); + final db = await _getInstance().database; + await db.transaction((txn) async { + await txn.execute('DROP TRIGGER $_kSizeTriggerName'); + await txn.execute(_getSizeTriggerQuery(maxTileAmount)); + await txn.insert( + _kTileCacheConfigTable, + { + _kConfigKeyColumn: _kMaxTileAmountConfig, + _kConfigValueColumn: maxTileAmount.toString() + }, + conflictAlgorithm: ConflictAlgorithm.replace); + List currentTilesAmountResult = + await txn.rawQuery('select count(*) from $_kTilesTable'); + final currentTilesAmount = currentTilesAmountResult.isNotEmpty + ? currentTilesAmountResult.first['count(*)'] + : 0; + // if current tileAmount bigger then new one, then + // from tile tables deleted most oldest overflow rows. + if (currentTilesAmount > maxTileAmount) { + List lastValidTileDateResult = await txn + .rawQuery('select $_kUpdateDateColumn from $_kTilesTable order by' + ' $_kUpdateDateColumn asc ' + 'limit 1 offset ${currentTilesAmount - maxTileAmount}'); + if (lastValidTileDateResult.isEmpty) return; + final lastValidTileDate = + lastValidTileDateResult.first[_kUpdateDateColumn]; + if (lastValidTileDate == null) return; + await txn.delete(_kTilesTable, + where: '$_kUpdateDateColumn <= ?', whereArgs: [lastValidTileDate]); + } + }); + } + + /// clean cached tiles db + static Future cleanCache() async { + if (!(await isDbFileExists)) return; + final db = await _getInstance().database; + final maxTileAmount = await maxCachedTilesAmount; + await _createCacheTable(db, maxTileAmount: maxTileAmount); + } + + /// [File] with cached tiles db + static Future get dbFile async => File(await _path); + + /// [bool] flag for [dbFile] existence + static Future get isDbFileExists async => (await dbFile).exists(); + + /// cached tiles db sizes in bytes + static Future get cacheDbSize async { + if (!(await isDbFileExists)) return 0; + return File((await _path)).length(); + } + + /// cached tiles amount + static Future get cachedTilesAmount async { + if (!(await isDbFileExists)) return 0; + final db = await _getInstance().database; + List result = await db.rawQuery('select count(*) from $_kTilesTable'); + return result.isNotEmpty ? result.first['count(*)'] : 0; + } + + /// current maxCachedTilesAmount + static Future get maxCachedTilesAmount async { + if (!(await isDbFileExists)) return kDefaultMaxTileAmount; + final db = await _getInstance().database; + List result = await db.rawQuery( + 'select $_kConfigValueColumn from $_kTileCacheConfigTable where $_kConfigKeyColumn = "$_kMaxTileAmountConfig" limit 1'); + return result.isNotEmpty + ? int.parse(result.first[_kConfigValueColumn]) + : kDefaultMaxTileAmount; + } +} diff --git a/lib/src/layer/tile_provider/tile_provider.dart b/lib/src/layer/tile_provider/tile_provider.dart index 01c775e5b..18bb04b76 100644 --- a/lib/src/layer/tile_provider/tile_provider.dart +++ b/lib/src/layer/tile_provider/tile_provider.dart @@ -7,6 +7,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/core/util.dart' as util; export 'package:flutter_map/src/layer/tile_provider/mbtiles_image_provider.dart'; +export 'package:flutter_map/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_tile_provider.dart'; abstract class TileProvider { const TileProvider(); diff --git a/test/tile_calculator_test.dart b/test/tile_calculator_test.dart new file mode 100644 index 000000000..453ad99a6 --- /dev/null +++ b/test/tile_calculator_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_tile_provider.dart'; +import 'package:latlong/latlong.dart'; +import 'package:test/test.dart'; + +void main() { + test('tile_calculator_test', () { + final resultRange = StorageCachingTileProvider.approximateTileAmount( + bounds: LatLngBounds.fromPoints( + [LatLng(-33.5597, -70.77941), LatLng(-33.33282, -70.49102)]), + minZoom: 10, + maxZoom: 16); + final tilesCount = resultRange; + assert(tilesCount == 3580); + }); +}