From 1e0cf7833c958fe4f10fe2372b373f4ed5fe202f Mon Sep 17 00:00:00 2001 From: bugDim88 Date: Tue, 24 Mar 2020 15:33:16 +0300 Subject: [PATCH 1/8] init commit # Conflicts: # example/lib/widgets/drawer.dart --- .idea/libraries/Dart_Packages.xml | 348 ++++++++++-------- example/lib/main.dart | 2 + example/lib/pages/auto_cached_tiles.dart | 46 +++ example/lib/widgets/drawer.dart | 9 +- .../storage_caching_db.dart | 137 +++++++ .../storage_caching_tile_provider.dart | 149 ++++++++ .../layer/tile_provider/tile_provider.dart | 1 + test/tile_calculator_test.dart | 16 + 8 files changed, 553 insertions(+), 155 deletions(-) create mode 100644 example/lib/pages/auto_cached_tiles.dart create mode 100644 lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_db.dart create mode 100644 lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_tile_provider.dart create mode 100644 test/tile_calculator_test.dart diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml index 65a35b565..96288edd7 100644 --- a/.idea/libraries/Dart_Packages.xml +++ b/.idea/libraries/Dart_Packages.xml @@ -5,622 +5,662 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - + + + + + + - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/lib/main.dart b/example/lib/main.dart index aeb609bd2..b9e41db96 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map_example/pages/auto_cached_tiles.dart'; import './pages/animated_map_controller.dart'; import './pages/circle.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..021f05b81 --- /dev/null +++ b/example/lib/pages/auto_cached_tiles.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong/latlong.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: Padding( + padding: EdgeInsets.all(8.0), + child: Column( + children: [ + Padding( + padding: EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Text( + 'This is an offline map that is showing Anholt Island, Denmark.'), + ), + Flexible( + child: FlutterMap( + options: MapOptions( + center: LatLng( 55.753215, 37.622504), + minZoom: 12.0, + maxZoom: 18.0, + zoom: 13.0, + ), + layers: [ + TileLayerOptions( + tileProvider: StorageCachingTileProvider(), + urlTemplate: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + subdomains: ['a', 'b', 'c'], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/widgets/drawer.dart b/example/lib/widgets/drawer.dart index 8cac00f0a..57bf2ca75 100644 --- a/example/lib/widgets/drawer.dart +++ b/example/lib/widgets/drawer.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map_example/pages/auto_cached_tiles.dart'; +import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart'; import '../pages/animated_map_controller.dart'; import '../pages/circle.dart'; -import '../pages/custom_crs.dart'; import '../pages/esri.dart'; import '../pages/home.dart'; import '../pages/map_controller.dart'; @@ -28,6 +29,12 @@ Drawer buildDrawer(BuildContext context, String currentRoute) { child: Text('Flutter Map Examples'), ), ), + ListTile( + title: const Text('AutoCachedTiles'), + selected: currentRoute == AutoCachedTilesPage.route, + onTap: () => Navigator.pushReplacementNamed( + context, AutoCachedTilesPage.route), + ), ListTile( title: const Text('OpenStreetMap'), selected: currentRoute == HomePage.route, diff --git a/lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_db.dart b/lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_db.dart new file mode 100644 index 000000000..2403f57f8 --- /dev/null +++ b/lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_db.dart @@ -0,0 +1,137 @@ +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'; + +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 int kDefaultMaxTileCount = 3000; + static final kMaxRefreshRowsCount = 5; + 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 _kIdColumn = '_id'; + static final String _kUpdateDateColumn = '_lastUpdateColumn'; + static final String _kSizeTriggerName = 'size_trigger'; + 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; + } + + 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 + BEFORE INSERT on $_kTilesTable + WHEN (select count(*) from $_kTilesTable) > $tileCount + BEGIN + DELETE from $_kTilesTable where $_kIdColumn in + (select $_kIdColumn from $_kTilesTable order by $_kUpdateDateColumn asc LIMIT $kMaxRefreshRowsCount); + END; + '''; + + void _onConfigure(Database db) async {} + + void _onCreate(Database db, int version) async { + final batch = db.batch(); + batch.execute('DROP TABLE IF EXISTS $_kTilesTable'); + batch.execute(''' + CREATE TABLE $_kTilesTable( + $_kIdColumn INTEGER PRIMARY KEY AUTOINCREMENT, + $_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(kDefaultMaxTileCount)); + await batch.commit(); + } + + void _onUpgrade(Database db, int oldVersion, int newVersion) async {} + + Future> getTile(Coords coords, + {Duration valid}) async { + List result = await (await 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.fromMicrosecondsSinceEpoch( + result.first[_kUpdateDateColumn])) + : null; + } + + Future saveTile(Uint8List tile, Coords cords) async { + await (await database).insert( + _kTilesTable, + { + _kZoomLevelColumn: cords.z, + _kTileColumnColumn: cords.x, + _kTileRowColumn: cords.y, + _kUpdateDateColumn: DateTime.now().millisecondsSinceEpoch, + _kTileDataColumn: tile + }, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future changeMaxTileCount(int maxTileCount) async { + final db = await _getInstance().database; + await db.transaction((txn) async { + await txn.execute('DROP TRIGGER $_kSizeTriggerName'); + await txn.execute(_getSizeTriggerQuery(maxTileCount)); + }); + } +} 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..772f9a425 --- /dev/null +++ b/lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_tile_provider.dart @@ -0,0 +1,149 @@ +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/storage_caching_db.dart'; +import 'package:flutter_map/src/layer/tile_provider/tile_provider.dart'; +import 'package:http/http.dart' as http; +import 'package:tuple/tuple.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; + final TileStorageCachingManager _tileStorageCachingManager = + TileStorageCachingManager(); + + 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()); + } + + /// [maxTileCount] - 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 changeMaxTileCount(int maxTileCount) async => + TileStorageCachingManager.changeMaxTileCount(maxTileCount); + + /// 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 + /// [approximateTileRange]. + /// 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 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; + final TileStorageCachingManager _tileStorageCachingManager = + TileStorageCachingManager(); + + 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/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..423845da0 --- /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.approximateTileRange( + bounds: LatLngBounds.fromPoints( + [LatLng(-33.5597, -70.77941), LatLng(-33.33282, -70.49102)]), + minZoom: 10, + maxZoom: 16); + final tilesCount = resultRange.length; + assert(tilesCount == 3580); + }); +} From faaadb636c5d54ce4e6d7630efc60b5ad91fd8c2 Mon Sep 17 00:00:00 2001 From: bugDim88 Date: Thu, 26 Mar 2020 14:23:45 +0300 Subject: [PATCH 2/8] example improved --- example/lib/pages/auto_cached_tiles.dart | 428 +++++++++++++++++- example/lib/widgets/drawer.dart | 12 +- .../storage_caching_tile_provider.dart | 9 +- ...dart => tile_storage_caching_manager.dart} | 49 +- 4 files changed, 452 insertions(+), 46 deletions(-) rename lib/src/layer/tile_provider/storage_caching_tile_provider/{storage_caching_db.dart => tile_storage_caching_manager.dart} (71%) diff --git a/example/lib/pages/auto_cached_tiles.dart b/example/lib/pages/auto_cached_tiles.dart index 021f05b81..91c0185e7 100644 --- a/example/lib/pages/auto_cached_tiles.dart +++ b/example/lib/pages/auto_cached_tiles.dart @@ -1,6 +1,10 @@ +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'; @@ -10,37 +14,411 @@ class AutoCachedTilesPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text('AutoCachedTiles Map')), - drawer: buildDrawer(context, route), - body: Padding( - padding: EdgeInsets.all(8.0), - child: Column( - children: [ - Padding( - padding: EdgeInsets.only(top: 8.0, bottom: 8.0), - child: Text( - 'This is an offline map that is showing Anholt Island, Denmark.'), - ), - Flexible( - child: FlutterMap( - options: MapOptions( - center: LatLng( 55.753215, 37.622504), - minZoom: 12.0, - maxZoom: 18.0, - zoom: 13.0, + 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), + )); + }); + } + + Future _loadMap( + StorageCachingTileProvider tileProvider, TileLayerOptions options) async { + _hideKeyboard(); + final zoomMin = int.tryParse(minZoomController.text); + final zoomMax = int.tryParse(maxZoomController.text) ?? zoomMin; + if (zoomMin == null) { + _showErrorSnack('At least zoomMin must be defined!'); + return; + } + if (zoomMin < 0 || zoomMin > 19) { + _showErrorSnack('valid zoom value must be inside 1..19 range'); + return; + } + if (zoomMax < zoomMin) { + _showErrorSnack('Max zoom must be bigger than min zoom'); + return; + } + if (_selectedBounds == null) { + _showErrorSnack('bounds of caching area are not defined'); + return; + } + final approximateTileCount = + StorageCachingTileProvider.approximateTileRange( + bounds: _selectedBounds, minZoom: zoomMin, maxZoom: zoomMax) + .length; + if (approximateTileCount > + StorageCachingTileProvider.kMaxPreloadTileAreaCount) { + _showErrorSnack( + 'tiles ammount $approximateTileCount bigger than default ${StorageCachingTileProvider.kMaxPreloadTileAreaCount}'); + 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); + }, ), - layers: [ - TileLayerOptions( - tileProvider: StorageCachingTileProvider(), - urlTemplate: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - subdomains: ['a', 'b', 'c'], + 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.subtitle1), + 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.subtitle1), + 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.subtitle1, + ), + ) + ], + ), + ), + SizedBox( + height: 8, + ), + Text('$tileIndex/$tileAmount', + style: Theme.of(context).textTheme.subtitle2) + ], + ); + } + + @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.delete), + onPressed: _deleteCachedMap, + ), + IconButton( + icon: Icon(Icons.cloud_download), + onPressed: () => _loadMap(tileProvider, tileLayerOptions), + ), + 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 57bf2ca75..835ffd574 100644 --- a/example/lib/widgets/drawer.dart +++ b/example/lib/widgets/drawer.dart @@ -29,12 +29,6 @@ Drawer buildDrawer(BuildContext context, String currentRoute) { child: Text('Flutter Map Examples'), ), ), - ListTile( - title: const Text('AutoCachedTiles'), - selected: currentRoute == AutoCachedTilesPage.route, - onTap: () => Navigator.pushReplacementNamed( - context, AutoCachedTilesPage.route), - ), ListTile( title: const Text('OpenStreetMap'), selected: currentRoute == HomePage.route, @@ -42,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 index 772f9a425..5bd4d02c6 100644 --- 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 @@ -4,10 +4,11 @@ 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/storage_caching_db.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] @@ -29,12 +30,6 @@ class StorageCachingTileProvider extends TileProvider { Coords(coords.x.toInt(), coords.y.toInt())..z = coords.z.toInt()); } - /// [maxTileCount] - 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 changeMaxTileCount(int maxTileCount) async => - TileStorageCachingManager.changeMaxTileCount(maxTileCount); - /// 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 diff --git a/lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_db.dart b/lib/src/layer/tile_provider/storage_caching_tile_provider/tile_storage_caching_manager.dart similarity index 71% rename from lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_db.dart rename to lib/src/layer/tile_provider/storage_caching_tile_provider/tile_storage_caching_manager.dart index 2403f57f8..626815785 100644 --- a/lib/src/layer/tile_provider/storage_caching_tile_provider/storage_caching_db.dart +++ b/lib/src/layer/tile_provider/storage_caching_tile_provider/tile_storage_caching_manager.dart @@ -7,12 +7,13 @@ 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 int kDefaultMaxTileCount = 3000; + static final int kDefaultMaxTileCount = 3000; static final kMaxRefreshRowsCount = 5; static final String _kDbName = 'tile_cach.db'; static final String _kTilesTable = 'tiles'; @@ -54,7 +55,7 @@ class TileStorageCachingManager { return _db; } - Future get _path async { + static Future get _path async { final databasePath = await getDatabasesPath(); final path = join(databasePath, _kDbName); await Directory(databasePath).create(recursive: true); @@ -71,9 +72,13 @@ class TileStorageCachingManager { END; '''; - void _onConfigure(Database db) async {} + Future _onConfigure(Database db) async {} - void _onCreate(Database db, int version) async { + Future _onCreate(Database db, int version) => _createCacheTable(db); + + Future _onUpgrade(Database db, int oldVersion, int newVersion) async {} + + static Future _createCacheTable(Database db) async { final batch = db.batch(); batch.execute('DROP TABLE IF EXISTS $_kTilesTable'); batch.execute(''' @@ -97,8 +102,6 @@ class TileStorageCachingManager { await batch.commit(); } - void _onUpgrade(Database db, int oldVersion, int newVersion) async {} - Future> getTile(Coords coords, {Duration valid}) async { List result = await (await database) @@ -127,11 +130,41 @@ class TileStorageCachingManager { conflictAlgorithm: ConflictAlgorithm.replace); } - static Future changeMaxTileCount(int maxTileCount) async { + /// [maxTileCount] - 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 changeMaxTileCount(int maxTileAmount) async { final db = await _getInstance().database; await db.transaction((txn) async { await txn.execute('DROP TRIGGER $_kSizeTriggerName'); - await txn.execute(_getSizeTriggerQuery(maxTileCount)); + await txn.execute(_getSizeTriggerQuery(maxTileAmount)); }); } + + /// clean cached tiles db + static Future cleanCache() async { + if (!(await isDbFileExists)) return; + final db = await _getInstance().database; + await _createCacheTable(db); + } + + /// [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; + } } From 1d0fda7536a817b3edbbfecd46f36248c6980207 Mon Sep 17 00:00:00 2001 From: bugDim88 Date: Fri, 27 Mar 2020 07:36:04 +0300 Subject: [PATCH 3/8] added persisted max tile config --- example/lib/pages/auto_cached_tiles.dart | 110 +++++++++++++++--- .../storage_caching_tile_provider.dart | 40 +++++-- .../tile_storage_caching_manager.dart | 107 ++++++++++++++--- test/tile_calculator_test.dart | 4 +- 4 files changed, 219 insertions(+), 42 deletions(-) diff --git a/example/lib/pages/auto_cached_tiles.dart b/example/lib/pages/auto_cached_tiles.dart index 91c0185e7..7f1f2358c 100644 --- a/example/lib/pages/auto_cached_tiles.dart +++ b/example/lib/pages/auto_cached_tiles.dart @@ -87,35 +87,111 @@ class _AutoCachedTilesPageContentState }); } - Future _loadMap( - StorageCachingTileProvider tileProvider, TileLayerOptions options) async { - _hideKeyboard(); + 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.headline4, + ), + 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.changeMaxTileCount(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; + return false; } if (zoomMin < 0 || zoomMin > 19) { _showErrorSnack('valid zoom value must be inside 1..19 range'); - return; + return false; } if (zoomMax < zoomMin) { _showErrorSnack('Max zoom must be bigger than min zoom'); - return; + return false; } if (_selectedBounds == null) { _showErrorSnack('bounds of caching area are not defined'); - return; + 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.approximateTileRange( - bounds: _selectedBounds, minZoom: zoomMin, maxZoom: zoomMax) - .length; - if (approximateTileCount > - StorageCachingTileProvider.kMaxPreloadTileAreaCount) { + StorageCachingTileProvider.approximateTileAmount( + bounds: _selectedBounds, minZoom: zoomMin, maxZoom: zoomMax); + final maxTilesAmount = await TileStorageCachingManager.maxCachedTilesAmount; + if (approximateTileCount > maxTilesAmount) { _showErrorSnack( - 'tiles ammount $approximateTileCount bigger than default ${StorageCachingTileProvider.kMaxPreloadTileAreaCount}'); + 'tiles ammount $approximateTileCount bigger than current maximum $maxTilesAmount'); return; } await showDialog( @@ -404,6 +480,10 @@ class _AutoCachedTilesPageContentState child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ + IconButton( + icon: Icon(Icons.settings), + onPressed: _changeSettings, + ), IconButton( icon: Icon(Icons.delete), onPressed: _deleteCachedMap, @@ -412,6 +492,10 @@ class _AutoCachedTilesPageContentState 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/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 index 5bd4d02c6..4e8ec220e 100644 --- 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 @@ -17,8 +17,6 @@ export 'package:flutter_map/src/layer/tile_provider/storage_caching_tile_provide class StorageCachingTileProvider extends TileProvider { static final kMaxPreloadTileAreaCount = 3000; final Duration cachedValidDuration; - final TileStorageCachingManager _tileStorageCachingManager = - TileStorageCachingManager(); StorageCachingTileProvider( {this.cachedValidDuration = const Duration(days: 1)}); @@ -33,7 +31,7 @@ class StorageCachingTileProvider extends TileProvider { /// 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 - /// [approximateTileRange]. + /// [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] @@ -55,7 +53,7 @@ class StorageCachingTileProvider extends TileProvider { // get network tile final bytes = (await http.get(url)).bodyBytes; // save tile to cache - await _tileStorageCachingManager.saveTile(bytes, cord); + await TileStorageCachingManager.saveTile(bytes, cord); } catch (e) { errorsCount++; if (errorHandler != null) errorHandler(e); @@ -64,6 +62,34 @@ class StorageCachingTileProvider extends TileProvider { } } + ///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( @@ -100,8 +126,6 @@ class CachedTileImageProvider extends ImageProvider> { final String url; final Coords coords; final Duration cacheValidDuration; - final TileStorageCachingManager _tileStorageCachingManager = - TileStorageCachingManager(); CachedTileImageProvider(this.url, this.coords, {this.cacheValidDuration = const Duration(days: 1), @@ -122,7 +146,7 @@ class CachedTileImageProvider extends ImageProvider> { SynchronousFuture(coords); Future _loadAsync() async { - final localBytes = await _tileStorageCachingManager.getTile(coords); + final localBytes = await TileStorageCachingManager.getTile(coords); var bytes = localBytes?.item1; if ((DateTime.now().millisecondsSinceEpoch - (localBytes?.item2?.millisecondsSinceEpoch ?? 0)) > @@ -131,7 +155,7 @@ class CachedTileImageProvider extends ImageProvider> { // get network tile bytes = (await http.get(url)).bodyBytes; // save tile to cache - await _tileStorageCachingManager.saveTile(bytes, coords); + await TileStorageCachingManager.saveTile(bytes, coords); } catch (e) { if (netWorkErrorHandler != null) netWorkErrorHandler(e); } 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 index 626815785..7dd6a51a8 100644 --- 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 @@ -13,17 +13,21 @@ class TileStorageCachingManager { /// default value of maximum number of persisted tiles, /// and average tile size ~ 0.017 mb -> so default cache size ~ 51 mb - static final int kDefaultMaxTileCount = 3000; - static final kMaxRefreshRowsCount = 5; + 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 _kIdColumn = '_id'; 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(); @@ -64,26 +68,47 @@ class TileStorageCachingManager { static String _getSizeTriggerQuery(int tileCount) => ''' CREATE TRIGGER $_kSizeTriggerName - BEFORE INSERT on $_kTilesTable + AFTER INSERT on $_kTilesTable WHEN (select count(*) from $_kTilesTable) > $tileCount BEGIN - DELETE from $_kTilesTable where $_kIdColumn in - (select $_kIdColumn from $_kTilesTable order by $_kUpdateDateColumn asc LIMIT $kMaxRefreshRowsCount); + 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) => _createCacheTable(db); + Future _onCreate(Database db, int version) async { + await _createConfigTable(db); + await _createCacheTable(db); + } Future _onUpgrade(Database db, int oldVersion, int newVersion) async {} - static Future _createCacheTable(Database db) 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( - $_kIdColumn INTEGER PRIMARY KEY AUTOINCREMENT, $_kZoomLevelColumn INTEGER NOT NULL, $_kTileColumnColumn INTEGER NOT NULL, $_kTileRowColumn INTEGER NOT NULL, @@ -98,13 +123,16 @@ class TileStorageCachingManager { $_kTileRowColumn ) '''); - batch.execute(_getSizeTriggerQuery(kDefaultMaxTileCount)); + batch.execute(_getSizeTriggerQuery(maxTileAmount)); await batch.commit(); } - Future> getTile(Coords coords, + /// 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 database) + List result = await (await _getInstance().database) .rawQuery('select $_kTileDataColumn, $_kUpdateDateColumn from tiles ' 'where $_kZoomLevelColumn = ${coords.z} AND ' '$_kTileColumnColumn = ${coords.x} AND ' @@ -112,32 +140,61 @@ class TileStorageCachingManager { return result.isNotEmpty ? Tuple2( result.first[_kTileDataColumn], - DateTime.fromMicrosecondsSinceEpoch( - result.first[_kUpdateDateColumn])) + DateTime.fromMillisecondsSinceEpoch( + 1000 * result.first[_kUpdateDateColumn])) : null; } - Future saveTile(Uint8List tile, Coords cords) async { - await (await database).insert( + /// 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, + _kUpdateDateColumn: (DateTime.now().millisecondsSinceEpoch ~/ 1000), _kTileDataColumn: tile }, conflictAlgorithm: ConflictAlgorithm.replace); } - /// [maxTileCount] - maximum number of persisted tiles, default value is 3000, + /// [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 changeMaxTileCount(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]); + } }); } @@ -145,7 +202,8 @@ class TileStorageCachingManager { static Future cleanCache() async { if (!(await isDbFileExists)) return; final db = await _getInstance().database; - await _createCacheTable(db); + final maxTileAmount = await maxCachedTilesAmount; + await _createCacheTable(db, maxTileAmount: maxTileAmount); } /// [File] with cached tiles db @@ -167,4 +225,15 @@ class TileStorageCachingManager { 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/test/tile_calculator_test.dart b/test/tile_calculator_test.dart index 423845da0..453ad99a6 100644 --- a/test/tile_calculator_test.dart +++ b/test/tile_calculator_test.dart @@ -5,12 +5,12 @@ import 'package:test/test.dart'; void main() { test('tile_calculator_test', () { - final resultRange = StorageCachingTileProvider.approximateTileRange( + final resultRange = StorageCachingTileProvider.approximateTileAmount( bounds: LatLngBounds.fromPoints( [LatLng(-33.5597, -70.77941), LatLng(-33.33282, -70.49102)]), minZoom: 10, maxZoom: 16); - final tilesCount = resultRange.length; + final tilesCount = resultRange; assert(tilesCount == 3580); }); } From a6af720ccacc5d76a3b189a2ee903f000b6e4288 Mon Sep 17 00:00:00 2001 From: bugDim88 Date: Fri, 27 Mar 2020 08:00:34 +0300 Subject: [PATCH 4/8] analyzer fixes --- example/lib/main.dart | 2 +- example/lib/widgets/drawer.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index b9e41db96..b7641ffa8 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map_example/pages/auto_cached_tiles.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'; diff --git a/example/lib/widgets/drawer.dart b/example/lib/widgets/drawer.dart index 835ffd574..11866db5d 100644 --- a/example/lib/widgets/drawer.dart +++ b/example/lib/widgets/drawer.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map_example/pages/auto_cached_tiles.dart'; -import 'package:flutter_map_example/pages/custom_crs/custom_crs.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'; import '../pages/home.dart'; import '../pages/map_controller.dart'; From 828153f95adaa9e855a143abbfe40c44e28f6776 Mon Sep 17 00:00:00 2001 From: bugDim88 Date: Fri, 27 Mar 2020 08:36:15 +0300 Subject: [PATCH 5/8] README.md enhanced --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 From 1d9ce009b964741da7a704025ee9d17c0f68074f Mon Sep 17 00:00:00 2001 From: bugDim88 Date: Fri, 27 Mar 2020 09:17:22 +0300 Subject: [PATCH 6/8] rename --- .idea/libraries/Dart_Packages.xml | 8 ++++---- example/lib/pages/auto_cached_tiles.dart | 2 +- .../tile_storage_caching_manager.dart | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml index 96288edd7..c97db1370 100644 --- a/.idea/libraries/Dart_Packages.xml +++ b/.idea/libraries/Dart_Packages.xml @@ -348,7 +348,7 @@ - @@ -558,7 +558,7 @@ - @@ -626,7 +626,7 @@ - + @@ -655,7 +655,7 @@ - + diff --git a/example/lib/pages/auto_cached_tiles.dart b/example/lib/pages/auto_cached_tiles.dart index 7f1f2358c..af6320521 100644 --- a/example/lib/pages/auto_cached_tiles.dart +++ b/example/lib/pages/auto_cached_tiles.dart @@ -154,7 +154,7 @@ class _AutoCachedTilesPageContentState ); }); if (result == null || result == currentMaxTileAmount) return; - await TileStorageCachingManager.changeMaxTileCount(result); + await TileStorageCachingManager.changeMaxTileAmount(result); } bool _checkTileLoadParams() { 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 index 7dd6a51a8..e3871b128 100644 --- 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 @@ -163,7 +163,7 @@ class TileStorageCachingManager { /// [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 changeMaxTileCount(int maxTileAmount) async { + 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 { From 2171fb55452e11a66050d2b78ee3b5db0c32e777 Mon Sep 17 00:00:00 2001 From: bugDim88 Date: Sat, 28 Mar 2020 10:36:35 +0300 Subject: [PATCH 7/8] back to original material design spec --- example/lib/pages/auto_cached_tiles.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/example/lib/pages/auto_cached_tiles.dart b/example/lib/pages/auto_cached_tiles.dart index af6320521..24bc7b58b 100644 --- a/example/lib/pages/auto_cached_tiles.dart +++ b/example/lib/pages/auto_cached_tiles.dart @@ -100,7 +100,7 @@ class _AutoCachedTilesPageContentState title: Text('Aproximate tile amount'), content: Text( '~ $approximateTileCount', - style: Theme.of(ctx).textTheme.headline4, + style: Theme.of(ctx).textTheme.display1, ), actions: [ FlatButton( @@ -282,7 +282,7 @@ class _AutoCachedTilesPageContentState child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('BOUNDS', style: Theme.of(context).textTheme.subtitle1), + Text('BOUNDS', style: Theme.of(context).textTheme.subhead), SizedBox( width: boundsInputSize, child: TextField( @@ -351,7 +351,7 @@ class _AutoCachedTilesPageContentState child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('ZOOM', style: Theme.of(context).textTheme.subtitle1), + Text('ZOOM', style: Theme.of(context).textTheme.subhead), SizedBox( width: zoomInputWidth, child: TextField( @@ -417,7 +417,7 @@ class _AutoCachedTilesPageContentState alignment: Alignment.center, child: Text( (tileIndex / tileAmount * 100).toInt().toString(), - style: Theme.of(context).textTheme.subtitle1, + style: Theme.of(context).textTheme.subhead, ), ) ], @@ -427,7 +427,7 @@ class _AutoCachedTilesPageContentState height: 8, ), Text('$tileIndex/$tileAmount', - style: Theme.of(context).textTheme.subtitle2) + style: Theme.of(context).textTheme.subtitle) ], ); } From 6b2e26adcba9f12ebfcd576bd6046f47d0b6975c Mon Sep 17 00:00:00 2001 From: Daimon <33193287+bugDim88@users.noreply.github.com> Date: Thu, 9 Apr 2020 13:05:17 +0300 Subject: [PATCH 8/8] Delete Dart_Packages.xml --- .idea/libraries/Dart_Packages.xml | 668 ------------------------------ 1 file changed, 668 deletions(-) delete mode 100644 .idea/libraries/Dart_Packages.xml diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml deleted file mode 100644 index c97db1370..000000000 --- a/.idea/libraries/Dart_Packages.xml +++ /dev/null @@ -1,668 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file