feat: Modernize map selection and tile cache

This commit is contained in:
Janez T
2026-04-26 08:41:33 +02:00
parent b040a8bd3a
commit e1400cf1ec
12 changed files with 1985 additions and 533 deletions

View File

@@ -1,7 +1,35 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/offline_map_caching_provider.dart';
import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
void main() {
late Directory tempDir;
final cache = OfflineTileCacheService.instance;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('offline_map_provider_test_');
await cache.resetForTesting();
cache.setBaseDirForTesting('${tempDir.path}/tiles');
cache.setDatabasePathForTesting('${tempDir.path}/tiles.db');
});
tearDown(() async {
await cache.resetForTesting();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('parses query-style tile URLs used by Google layers', () {
final coords = OfflineMapCachingProvider.parseTileUrlForTesting(
'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13',
@@ -35,4 +63,48 @@ void main() {
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
);
});
test('returns cached raw bytes directly', () async {
const url =
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267';
final template = OfflineMapCachingProvider.extractUrlTemplateForTesting(url);
final styleHash = cache.styleHashFromUrl(template);
final bytes = Uint8List.fromList([1, 2, 3]);
await cache.saveStyleMeta(
styleHash,
displayName: 'Esri',
urlTemplate: template,
);
await cache.putTile(
styleHash,
9,
267,
173,
bytes,
contentType: 'image/jpeg',
sourceUrl: url,
);
final provider = OfflineMapCachingProvider(_NullMapCachingProvider());
final tile = await provider.getTile(url);
expect(tile, isNotNull);
expect(tile!.bytes, bytes);
});
}
class _NullMapCachingProvider implements MapCachingProvider {
@override
bool get isSupported => true;
@override
Future<CachedMapTile?> getTile(String url) async => null;
@override
Future<void> putTile({
required String url,
required CachedMapTileMetadata metadata,
Uint8List? bytes,
}) async {}
}

View File

@@ -0,0 +1,118 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
void main() {
late Directory tempDir;
final cache = OfflineTileCacheService.instance;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('offline_tile_cache_test_');
await cache.resetForTesting();
cache.setBaseDirForTesting('${tempDir.path}/tiles');
cache.setDatabasePathForTesting('${tempDir.path}/tiles.db');
});
tearDown(() async {
await cache.resetForTesting();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('stores original bytes and content type without conversion', () async {
const styleHash = 'abcdef123456';
final bytes = Uint8List.fromList([1, 2, 3, 4]);
await cache.saveStyleMeta(
styleHash,
displayName: 'Test layer',
urlTemplate: 'https://example.com/{z}/{x}/{y}.png',
);
await cache.putTile(
styleHash,
8,
12,
34,
bytes,
contentType: 'image/png; charset=binary',
sourceUrl: 'https://example.com/8/12/34.png',
);
final tile = await cache.getTileData(styleHash, 8, 12, 34);
expect(tile, isNotNull);
expect(tile!.bytes, bytes);
expect(tile.contentType, 'image/png');
expect(await cache.hasTile(styleHash, 8, 12, 34), isTrue);
expect(await cache.getCacheSize(), bytes.length);
final styles = await cache.listStylesDetailed();
expect(styles, hasLength(1));
expect(styles.single.tileCount, 1);
expect(styles.single.sizeBytes, bytes.length);
});
test('hydrates existing disk cache into sqlite on first access', () async {
const styleHash = 'fedcba654321';
final base = Directory('${tempDir.path}/tiles/$styleHash/9/13');
await base.create(recursive: true);
final file = File('${base.path}/42.avif');
await file.writeAsBytes([9, 8, 7], flush: true);
await File('${tempDir.path}/tiles/$styleHash/meta.json').writeAsString(
jsonEncode({
'displayName': 'Legacy layer',
'urlTemplate': 'https://example.com/{z}/{x}/{y}.avif',
}),
flush: true,
);
final tiles = await cache.listTilesForStyle(styleHash);
expect(tiles, hasLength(1));
expect(tiles.single.z, 9);
expect(tiles.single.x, 13);
expect(tiles.single.y, 42);
final tile = await cache.getTileData(styleHash, 9, 13, 42);
expect(tile, isNotNull);
expect(tile!.bytes, Uint8List.fromList([9, 8, 7]));
expect(tile.contentType, 'image/avif');
final styles = await cache.listStylesDetailed();
expect(styles.single.displayName, 'Legacy layer');
expect(styles.single.tileCount, 1);
expect(styles.single.sizeBytes, 3);
});
test('deletes style metadata and files', () async {
const styleHash = 'aabbccddeeff';
await cache.saveStyleMeta(
styleHash,
displayName: 'Delete me',
urlTemplate: 'https://example.com/{z}/{x}/{y}.jpg',
);
await cache.putTile(
styleHash,
1,
2,
3,
Uint8List.fromList([5, 6]),
contentType: 'image/jpeg',
);
expect(await cache.getCacheSize(), 2);
await cache.deleteStyle(styleHash);
expect(await cache.getCacheSize(), 0);
expect(await cache.getTileData(styleHash, 1, 2, 3), isNull);
expect(await Directory('${tempDir.path}/tiles/$styleHash').exists(), isFalse);
});
}

View File

@@ -61,7 +61,7 @@ void main() {
);
});
testWidgets('received bubbles show signal chips on double tap', (
testWidgets('received bubbles open details on double tap', (
tester,
) async {
final harness = await _TestHarness.create();
@@ -95,17 +95,19 @@ void main() {
expect(find.text('-84'), findsNothing);
await _doubleTap(tester, find.text('Inbound message'));
await tester.pumpAndSettle();
expect(find.text('1 hop'), findsOneWidget);
expect(find.text('Fair'), findsOneWidget);
expect(find.text('-84'), findsOneWidget);
expect(find.text('6.0'), findsOneWidget);
expect(find.text('Message details'), findsOneWidget);
expect(find.text('1 hop'), findsNothing);
expect(find.text('Fair'), findsNothing);
expect(find.text('-84'), findsNothing);
expect(find.text('6.0'), findsNothing);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('delivered direct bubbles show timing chips on double tap', (
testWidgets('delivered direct bubbles open details on double tap', (
tester,
) async {
final harness = await _TestHarness.create();
@@ -137,15 +139,17 @@ void main() {
expect(find.text('320ms'), findsNothing);
await _doubleTap(tester, find.text('Outbound message'));
await tester.pumpAndSettle();
expect(find.text('Direct'), findsOneWidget);
expect(find.text('320ms'), findsOneWidget);
expect(find.text('Message details'), findsOneWidget);
expect(find.text('Direct'), findsNothing);
expect(find.text('320ms'), findsNothing);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('sent channel bubbles show echo chips on double tap', (
testWidgets('sent channel bubbles open details on double tap', (
tester,
) async {
final harness = await _TestHarness.create();
@@ -180,10 +184,12 @@ void main() {
expect(find.text('-76'), findsNothing);
await _doubleTap(tester, find.text('Broadcast message'));
await tester.pumpAndSettle();
expect(find.text('x2'), findsOneWidget);
expect(find.text('-76'), findsOneWidget);
expect(find.text('5.0'), findsOneWidget);
expect(find.text('Message details'), findsOneWidget);
expect(find.text('x2'), findsNothing);
expect(find.text('-76'), findsNothing);
expect(find.text('5.0'), findsNothing);
} finally {
await _disposeHarness(tester, harness);
}