local_mbtiles_server 0.1.0
local_mbtiles_server: ^0.1.0 copied to clipboard
Serve SQLCipher-encrypted and plain MBTiles over localhost HTTP so any map SDK (MapLibre, Mapbox, etc.) can load offline tiles via standard URL templates.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:local_mbtiles_server/local_mbtiles_server.dart';
import 'map_screen.dart';
import 'map_session.dart';
import 'sqlcipher_password.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const LocalMbtilesServerExampleApp());
}
class LocalMbtilesServerExampleApp extends StatelessWidget {
const LocalMbtilesServerExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'local_mbtiles_server example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const ExampleHomePage(),
);
}
}
class ExampleHomePage extends StatefulWidget {
const ExampleHomePage({super.key});
@override
State<ExampleHomePage> createState() => _ExampleHomePageState();
}
class _ExampleHomePageState extends State<ExampleHomePage> {
LocalMbtilesServer? _server;
bool _loading = false;
String? _error;
String? _baseUrl;
Map<String, dynamic>? _health;
String? _sourceId;
Map<String, dynamic>? _metadata;
MapSession? _mapSession;
Future<void> _fetchDemo() async {
setState(() {
_loading = true;
_error = null;
_baseUrl = null;
_health = null;
_sourceId = null;
_metadata = null;
_mapSession = null;
});
await _tearDown();
try {
final path = await _copySampleToTemp(demoMbtilesAsset);
final source = await MbtilesSource.open(
path: path,
id: demoMbtilesSourceId,
password: demoSqlCipherPassword,
);
final server = LocalMbtilesServer(config: MbtilesServerConfig(port: 0));
await server.register(source);
await server.start();
final baseUrl = server.baseUrl;
final health = server.health.toJson();
final mbtilesMeta = await server.getMetadata(demoMbtilesSourceId);
final mapSession = MapSession.fromMetadata(
baseUrl: baseUrl,
sourceId: demoMbtilesSourceId,
metadata: mbtilesMeta,
);
final metadata = mbtilesMeta.toJson();
if (!mounted) {
await server.stop();
return;
}
setState(() {
_server = server;
_baseUrl = baseUrl;
_health = health;
_sourceId = demoMbtilesSourceId;
_metadata = metadata;
_mapSession = mapSession;
_loading = false;
});
} catch (error) {
await _tearDown();
if (!mounted) {
return;
}
setState(() {
_error = '$error';
_loading = false;
});
}
}
Future<void> _tearDown() async {
final server = _server;
_server = null;
if (server != null) {
await server.stop();
}
}
Future<String> _copySampleToTemp(String assetPath) async {
final bytes = await rootBundle.load(assetPath);
final directory = await Directory.systemTemp.createTemp(
'local_mbtiles_server_',
);
final fileName = assetPath.split('/').last;
final file = File('${directory.path}/$fileName');
await file.writeAsBytes(
bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes),
);
return file.path;
}
@override
void dispose() {
_tearDown();
super.dispose();
}
@override
Widget build(BuildContext context) {
final canOpenMap = _mapSession != null && _sourceId != null && !_loading;
return Scaffold(
appBar: AppBar(title: const Text('local_mbtiles_server')),
floatingActionButton: canOpenMap
? FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MapScreen(session: _mapSession!),
),
);
},
icon: const Icon(Icons.map_outlined),
label: const Text('Show tile on map'),
)
: null,
body: Padding(
padding: const EdgeInsets.all(24),
child: ListView(
children: [
Text(
'Example demo',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
const _SampleMbtilesSection(),
const SizedBox(height: 16),
FilledButton(
onPressed: _loading ? null : _fetchDemo,
child: Text(_loading ? 'Fetching...' : 'Fetch status & metadata'),
),
const SizedBox(height: 24),
if (_loading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
SelectableText(
'Error:\n$_error',
style: TextStyle(color: Theme.of(context).colorScheme.error),
)
else if (_health != null) ...[
_ResultsSection(
baseUrl: _baseUrl!,
health: _health!,
sourceId: _sourceId!,
metadata: _metadata!,
),
],
],
),
),
);
}
}
class _SampleMbtilesSection extends StatelessWidget {
const _SampleMbtilesSection();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Sample MBTiles', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Bundled synthetic raster overlay over San Francisco '
'(zooms 12–15). Zoom out to see the base map; zoom in to see '
'the colored localhost tiles.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
const _InfoRow(label: 'asset', value: demoMbtilesAsset),
const _InfoRow(label: 'sourceId', value: demoMbtilesSourceId),
const _InfoRow(label: 'format', value: 'png overlay (z12–15)'),
const _InfoRow(label: 'encryption', value: 'SQLCipher passphrase'),
const Divider(height: 24),
Text(
'SQLCipher password (passed to open)',
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 4),
SelectableText(
demoSqlCipherPassword,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
],
),
),
);
}
}
class _ResultsSection extends StatelessWidget {
const _ResultsSection({
required this.baseUrl,
required this.health,
required this.sourceId,
required this.metadata,
});
final String baseUrl;
final Map<String, dynamic> health;
final String sourceId;
final Map<String, dynamic> metadata;
@override
Widget build(BuildContext context) {
final encoder = const JsonEncoder.withIndent(' ');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Server running', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
_InfoRow(label: 'baseUrl', value: baseUrl),
const SizedBox(height: 24),
Text('server.health', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SelectableText(encoder.convert(health)),
const SizedBox(height: 24),
Text(
'server.getMetadata($sourceId)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SelectableText(encoder.convert(metadata)),
const SizedBox(height: 50),
],
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(
text: '$label: ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(text: value),
],
),
),
);
}
}