flutter_map_tile_caching 3.0.2
flutter_map_tile_caching: ^3.0.2 copied to clipboard

Plugin for flutter_map to provide an easy way to cache tiles and download map regions for offline use.

example/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show SchedulerBinding;
import 'package:flutter/services.dart' show FilteringTextInputFormatter;
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
import 'package:latlong2/latlong.dart' show LatLng;

void main() {
  runApp(DemoApp());
}

class DemoApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Map Caching Demo',
      theme: ThemeData(
        primarySwatch: Colors.green,
      ),
      home: AutoCachedTilesPage(),
    );
  }
}

class AutoCachedTilesPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text('Map Downloading & Caching Demo')),
        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 centerLatController = TextEditingController();
  final centerLngController = TextEditingController();
  final radiusController = TextEditingController();
  final minZoomController = TextEditingController();
  final maxZoomController = TextEditingController();

  late final MapController mapController;

  LatLngBounds? _selectedBoundsSqr;
  List<double>? _selectedBoundsCir;
  RegionType selectedType = RegionType.circle;

  final decimalInputFormatter = FilteringTextInputFormatter(
      RegExp(r'^-?\d{0,3}\.?\d{0,6}$'),
      allow: true);

  ShapeChooser shapeChooser = ShapeChooser(
    RegionType.rectangle,
    fillColor: Colors.green.withAlpha(128),
    borderColor: Colors.green,
  );
  ShapeChooserResult? shapeChooserResult;

  @override
  void initState() {
    super.initState();
    northController.addListener(_handleBoundsInput);
    eastController.addListener(_handleBoundsInput);
    westController.addListener(_handleBoundsInput);
    southController.addListener(_handleBoundsInput);
    centerLatController.addListener(_handleCircleInput);
    centerLngController.addListener(_handleCircleInput);
    radiusController.addListener(_handleCircleInput);
    mapController = MapController();
  }

  @override
  void dispose() {
    northController.dispose();
    eastController.dispose();
    westController.dispose();
    southController.dispose();
    centerLatController.dispose();
    centerLngController.dispose();
    radiusController.dispose();
    minZoomController.dispose();
    maxZoomController.dispose();
    super.dispose();
  }

  void _handleBoundsInput() {
    final north =
        double.tryParse(northController.text) ?? _selectedBoundsSqr?.north;
    final east =
        double.tryParse(eastController.text) ?? _selectedBoundsSqr?.east;
    final west =
        double.tryParse(westController.text) ?? _selectedBoundsSqr?.west;
    final south =
        double.tryParse(southController.text) ?? _selectedBoundsSqr?.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(() => _selectedBoundsSqr = bounds);
  }

  void _handleCircleInput() {
    final lat =
        double.tryParse(centerLatController.text) ?? _selectedBoundsCir?[0];
    final lng =
        double.tryParse(centerLngController.text) ?? _selectedBoundsCir?[1];
    final rad =
        double.tryParse(radiusController.text) ?? _selectedBoundsCir?[2];
    if (lat == null || lng == null || rad == null) {
      return;
    }
    setState(() => _selectedBoundsCir = [lat, lng, rad]);
  }

  void _showErrorSnack(String errorMessage) async {
    SchedulerBinding.instance!.addPostFrameCallback((timeStamp) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(
        content: Text(errorMessage),
      ));
    });
  }

  Future<void> _loadMap(
    StorageCachingTileProvider tileProvider,
    TileLayerOptions options,
    bool background,
  ) async {
    _hideKeyboard();
    final zoomMin = int.tryParse(minZoomController.text);
    if (zoomMin == null) {
      _showErrorSnack(
          'Invalid zoom level. Minimum zoom level must be defined.');
      return;
    }
    final zoomMax = int.tryParse(maxZoomController.text) ?? zoomMin;
    if (zoomMin < 1 || zoomMin > 19 || zoomMax < 1 || zoomMax > 19) {
      _showErrorSnack(
          'Invalid zoom level. Must be inside 1-19 range (inclusive).');
      return;
    }
    if (zoomMax < zoomMin) {
      _showErrorSnack(
          'Invalid zoom level. Maximum zoom must be larger than or equal to minimum zoom.');
      return;
    }
    if ((_selectedBoundsSqr == null && selectedType == RegionType.rectangle) ||
        (_selectedBoundsCir == null && selectedType == RegionType.circle)) {
      _showErrorSnack('Invalid bounds area. Region bounds must be defined.');
      return;
    }
    if (selectedType == RegionType.circle) {
      final approximateTileCount = StorageCachingTileProvider.checkRegion(
        CircleRegion(
          LatLng(_selectedBoundsCir![0], _selectedBoundsCir![1]),
          _selectedBoundsCir![2],
        ).toDownloadable(zoomMin, zoomMax, options),
      );
      if (approximateTileCount >
          StorageCachingTileProvider.kMaxPreloadTileAreaCount) {
        _showErrorSnack(
            '$approximateTileCount exceeds maximum number of pre-cachable tiles (${StorageCachingTileProvider.kMaxPreloadTileAreaCount}). Try a smaller amount first.');
        return;
      }
    } else if (selectedType == RegionType.rectangle) {
      final approximateTileCount = StorageCachingTileProvider.checkRegion(
        RectangleRegion(_selectedBoundsSqr!)
            .toDownloadable(zoomMin, zoomMax, options),
      );
      if (approximateTileCount >
          StorageCachingTileProvider.kMaxPreloadTileAreaCount) {
        _showErrorSnack(
            '$approximateTileCount exceeds maximum number of pre-cachable tiles (${StorageCachingTileProvider.kMaxPreloadTileAreaCount}). Try a smaller amount first.');
        return;
      }
    } else {
      throw UnimplementedError();
    }
    if (!background)
      await showDialog<void>(
        context: context,
        builder: (ctx) => AlertDialog(
          title: Text('Downloading Area...'),
          content: StreamBuilder<DownloadProgress>(
            initialData: DownloadProgress.placeholder(),
            stream: (selectedType == RegionType.rectangle
                ? tileProvider.downloadRegion(
                    RectangleRegion(_selectedBoundsSqr!)
                        .toDownloadable(zoomMin, zoomMax, options))
                : (selectedType == RegionType.circle
                    ? tileProvider.downloadRegion(
                        CircleRegion(
                          LatLng(
                              _selectedBoundsCir![0], _selectedBoundsCir![1]),
                          _selectedBoundsCir![2],
                        ).toDownloadable(zoomMin, zoomMax, options),
                      )
                    : null)),
            builder: (ctx, snapshot) {
              if (snapshot.hasError) {
                return Text('error: ${snapshot.error.toString()}');
              }
              final tileIndex = snapshot.data?.completedTiles ?? 0;
              final tilesAmount = snapshot.data?.totalTiles ?? 0;
              final tilesErrored = snapshot.data?.erroredTiles ?? [];
              final progressPercentage = snapshot.data?.percentageProgress ?? 0;
              return getLoadProgresWidget(ctx, tileIndex, tilesAmount,
                  tilesErrored, progressPercentage);
            },
          ),
          actions: <Widget>[
            TextButton(
              child: Text('Cancel & Exit'),
              onPressed: () => Navigator.of(ctx).pop(),
            )
          ],
        ),
      );
    else {
      tileProvider.downloadRegionBackground(
        (selectedType == RegionType.rectangle
            ? RectangleRegion(_selectedBoundsSqr!)
                .toDownloadable(zoomMin, zoomMax, options)
            : (selectedType == RegionType.circle
                ? CircleRegion(
                    LatLng(_selectedBoundsCir![0], _selectedBoundsCir![1]),
                    _selectedBoundsCir![2],
                  ).toDownloadable(zoomMin, zoomMax, options)
                : null))!,
      );
    }
  }

  Future<void> _deleteCachedMap() async {
    _hideKeyboard();
    final currentCacheSize =
        await TileStorageCachingManager.cacheDbSize / 1024 / 1024;
    String currentCacheAmountString = '';
    final List<String> cacheNames = [];
    for (String cacheName in await TileStorageCachingManager.allCacheNames) {
      currentCacheAmountString +=
          '\nCached Tiles In \'$cacheName\': ${await TileStorageCachingManager.cachedTilesAmountName(cacheName)}';
      cacheNames.add(cacheName);
    }
    List<TextButton> buttons = [
      TextButton(
        child: Text('Cancel'),
        onPressed: () => Navigator.pop(context, 'false'),
      ),
      TextButton(
        child: Text('Clear All Cache'),
        onPressed: () => Navigator.pop(context, 'all'),
      ),
    ];
    if (currentCacheAmountString.trim() != '')
      cacheNames.forEach((cacheName) {
        if (cacheName.trim() != '')
          buttons.add(TextButton(
            child: Text(
              'Clear \'$cacheName\'',
            ),
            onPressed: () => Navigator.pop(
              context,
              cacheName,
            ),
          ));
      });
    final result = await showDialog<String>(
      context: context,
      barrierDismissible: false,
      builder: (ctx) => AlertDialog(
        title: Text('Clear Cache'),
        content:
            Text('Total Cache Size: ${currentCacheSize.toStringAsFixed(2)} MB'
                '$currentCacheAmountString'
                '\nAre you sure you want to clear the cache?'),
        actions: buttons,
      ),
    );
    if (result != 'false') {
      if (result == 'all')
        await TileStorageCachingManager.cleanAllCache();
      else
        await TileStorageCachingManager.cleanCacheName(result!);
      _showErrorSnack('Cache cleared successfully');
      setState(() {});
    }
  }

  void _hideKeyboard() => FocusScope.of(context).requestFocus(FocusNode());

  void _focusToBounds() {
    _hideKeyboard();
    mapController.fitBounds(_selectedBoundsSqr!,
        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: <Widget>[
          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: selectedType == RegionType.circle
                  ? Column(
                      mainAxisSize: MainAxisSize.min,
                      children: <Widget>[
                        Text('BOUNDS',
                            style: Theme.of(context).textTheme.subtitle1),
                        Row(
                          children: [
                            Column(
                              children: [
                                SizedBox(
                                  width: (boundsSectionWidth / 4 * 1.6) - 7.2,
                                  child: TextField(
                                    textAlign: TextAlign.center,
                                    decoration:
                                        InputDecoration(hintText: 'Center Lat'),
                                    inputFormatters: [decimalInputFormatter],
                                    keyboardType:
                                        TextInputType.numberWithOptions(
                                      decimal: true,
                                    ),
                                    controller: centerLatController,
                                  ),
                                ),
                                SizedBox(
                                  width: (boundsSectionWidth / 4 * 1.6) - 7.2,
                                  child: TextField(
                                    textAlign: TextAlign.center,
                                    decoration:
                                        InputDecoration(hintText: 'Center Lng'),
                                    inputFormatters: [decimalInputFormatter],
                                    keyboardType:
                                        TextInputType.numberWithOptions(
                                      decimal: true,
                                    ),
                                    controller: centerLngController,
                                  ),
                                ),
                              ],
                            ),
                            SizedBox(width: 20),
                            SizedBox(
                              width: (boundsSectionWidth / 4 * 1.6) - 7.2,
                              child: TextField(
                                textAlign: TextAlign.center,
                                decoration:
                                    InputDecoration(hintText: 'Radius (km)'),
                                inputFormatters: [decimalInputFormatter],
                                keyboardType: TextInputType.numberWithOptions(
                                  decimal: true,
                                ),
                                controller: radiusController,
                              ),
                            ),
                          ],
                        ),
                      ],
                    )
                  : Column(
                      mainAxisSize: MainAxisSize.min,
                      children: <Widget>[
                        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: <Widget>[
                              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,
          ),
          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: <Widget>[
                Text('ZOOM', style: Theme.of(context).textTheme.subtitle1),
                SizedBox(
                  width: zoomInputWidth,
                  child: TextField(
                    textAlign: TextAlign.center,
                    maxLength: 2,
                    decoration:
                        InputDecoration(counterText: '', hintText: 'min'),
                    inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                    keyboardType:
                        TextInputType.numberWithOptions(decimal: false),
                    controller: minZoomController,
                  ),
                ),
                SizedBox(
                  width: zoomInputWidth,
                  child: TextField(
                    textAlign: TextAlign.center,
                    decoration: InputDecoration(
                      counterText: '',
                      hintText: 'max',
                    ),
                    maxLength: 2,
                    inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                    keyboardType:
                        TextInputType.numberWithOptions(decimal: false),
                    controller: maxZoomController,
                  ),
                )
              ],
            ),
          )
        ],
      ),
    );
  }

  Widget getLoadProgresWidget(BuildContext context, int tileIndex,
      int tileAmount, List<String> tilesErrored, double progress) {
    if (tileAmount == 0) {
      tileAmount = 1;
    }
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        SizedBox(
          width: 50,
          height: 50,
          child: Stack(
            children: <Widget>[
              SizedBox(
                width: 50,
                height: 50,
                child: CircularProgressIndicator(
                  backgroundColor: Colors.grey,
                  value: progress / 100,
                ),
              ),
              Align(
                alignment: Alignment.center,
                child: Text(
                  progress == 100.0
                      ? '100%'
                      : (progress.toStringAsFixed(1) + '%'),
                  style: Theme.of(context).textTheme.subtitle1,
                ),
              )
            ],
          ),
        ),
        SizedBox(
          height: 8,
        ),
        Text(
          progress == 100.0
              ? 'Download Finished'
              : '${tilesErrored.length == 0 ? '' : ((tileIndex - tilesErrored.length).toString() + '/')}$tileIndex/$tileAmount\nPlease Wait',
          style: Theme.of(context).textTheme.subtitle2,
          textAlign: TextAlign.center,
        ),
        Visibility(
          visible: tilesErrored.length != 0,
          child: Expanded(
            child: Column(
              children: [
                SizedBox(height: 10),
                Text(
                  'Errored Tiles: ${tilesErrored.length}',
                  style: Theme.of(context).textTheme.subtitle2!.merge(TextStyle(
                        color: Colors.red,
                        fontWeight: FontWeight.bold,
                      )),
                  textAlign: TextAlign.center,
                ),
                SizedBox(height: 5),
                Expanded(
                  child: Container(
                    width: double.maxFinite,
                    child: ListView.builder(
                      reverse: true,
                      shrinkWrap: true,
                      physics: NeverScrollableScrollPhysics(),
                      itemBuilder: (context, index) {
                        String test = '';
                        try {
                          test = tilesErrored.reversed.toList()[index];
                        } catch (e) {} finally {
                          // ignore: control_flow_in_finally
                          return Column(
                            children: [
                              Text(
                                test
                                    .replaceAll('https://', '')
                                    .replaceAll('http://', '')
                                    .split('/')[0],
                                style: Theme.of(context)
                                    .textTheme
                                    .subtitle2!
                                    .merge(TextStyle(color: Colors.red)),
                                textAlign: TextAlign.start,
                              ),
                              Text(
                                test
                                    .replaceAll(
                                        test
                                            .replaceAll('https://', '')
                                            .replaceAll('http://', '')
                                            .split('/')[0],
                                        '')
                                    .replaceAll('https:///', '')
                                    .replaceAll('http:///', ''),
                                style: Theme.of(context)
                                    .textTheme
                                    .subtitle2!
                                    .merge(TextStyle(color: Colors.red)),
                                textAlign: TextAlign.start,
                              ),
                            ],
                          );
                        }
                      },
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }

  @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(51.49990436717166, -0.6769064891560369),
              maxZoom: 19.0,
              zoom: 13.0,
              interactiveFlags: InteractiveFlag.all & ~InteractiveFlag.rotate,
              onTap: (point) {
                setState(() {
                  shapeChooserResult = shapeChooser.onTapReciever(point);
                });
              },
            ),
            layers: [
              tileLayerOptions,
              _selectedBoundsSqr == null
                  ? PolygonLayerOptions()
                  : RectangleRegion(
                      _selectedBoundsSqr!,
                    ).toDrawable(
                      Colors.green.withAlpha(128),
                      Colors.green,
                    ),
              _selectedBoundsCir == null
                  ? PolygonLayerOptions()
                  : CircleRegion(
                      LatLng(_selectedBoundsCir![0], _selectedBoundsCir![1]),
                      _selectedBoundsCir![2],
                    ).toDrawable(
                      Colors.green.withAlpha(128),
                      Colors.green,
                    ),
              shapeChooserResult.toDrawable(),
            ],
          ),
        ),
        Padding(
          padding: EdgeInsets.only(top: 8.0, bottom: 8.0),
          child: Text(
            'Define region bounds and zoom levels for painting and downloading',
            textAlign: TextAlign.center,
          ),
        ),
        getBoundsInputWidget(context),
        Container(
          height: 56,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: <Widget>[
              IconButton(
                icon: Icon(
                  selectedType == RegionType.circle
                      ? Icons.add_circle
                      : Icons.add_circle_outline,
                  color:
                      selectedType == RegionType.circle ? Colors.green : null,
                ),
                onPressed: () => setState(() {
                  selectedType = RegionType.circle;
                }),
              ),
              IconButton(
                icon: Icon(
                  selectedType == RegionType.rectangle
                      ? Icons.add_box
                      : Icons.add_box_outlined,
                  color: selectedType == RegionType.rectangle
                      ? Colors.green
                      : null,
                ),
                onPressed: () => setState(() {
                  selectedType = RegionType.rectangle;
                }),
              ),
              IconButton(
                icon: Icon(
                  selectedType == RegionType.line
                      ? Icons.auto_graph_outlined
                      : Icons.show_chart,
                  color: selectedType == RegionType.line ? Colors.green : null,
                ),
                onPressed: () => setState(() {
                  selectedType = RegionType.line;
                }),
              ),
              IconButton(
                icon: Icon(
                  selectedType == RegionType.customPolygon
                      ? Icons.edit
                      : Icons.edit_off_outlined,
                  color: selectedType == RegionType.customPolygon
                      ? Colors.green
                      : null,
                ),
                onPressed: () => setState(() {
                  selectedType = RegionType.customPolygon;
                }),
              ),
              VerticalDivider(
                indent: 10,
                endIndent: 10,
                width: 0,
                thickness: 1,
              ),
              IconButton(
                icon: Icon(Icons.delete),
                onPressed: _deleteCachedMap,
              ),
              IconButton(
                icon: Icon(Icons.filter_center_focus),
                onPressed: _selectedBoundsSqr == null ||
                        selectedType != RegionType.rectangle
                    ? null
                    : _focusToBounds,
              ),
              IconButton(
                icon: Icon(Icons.download),
                onPressed: () {
                  _loadMap(tileProvider, tileLayerOptions, false);
                },
              ),
              IconButton(
                icon: Icon(Icons.downloading),
                onPressed: () async {
                  if (!await StorageCachingTileProvider
                      .requestIgnoreBatteryOptimizations(context))
                    _showErrorSnack(
                        'Ignore Battery Optimizations permission denied. Background download will only work whilst app is in foreground.');
                  _loadMap(tileProvider, tileLayerOptions, true);
                },
              ),
            ],
          ),
        ),
      ],
    );
  }
}