add initial project files for Flutter app with platform-specific configs and basic routing setup

This commit is contained in:
ImBenji
2026-02-26 16:20:54 +00:00
parent 2dd10c4b43
commit d460f0369e
135 changed files with 6524 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
import 'dart:typed_data';
import 'package:hive/hive.dart';
import 'package:http/http.dart' as http;
class HiveTileCache {
HiveTileCache._();
static const String _boxName = 'map_tile_cache_v1';
static const int _maxEntries = 6000;
static const Duration _staleAfter = Duration(days: 14);
static final Map<String, Future<Uint8List?>> _inFlight =
<String, Future<Uint8List?>>{};
static Future<void> init() async {
if (!Hive.isBoxOpen(_boxName)) {
await Hive.openBox(_boxName);
}
}
static Box? get _boxOrNull {
try {
if (!Hive.isBoxOpen(_boxName)) return null;
return Hive.box(_boxName);
} catch (_) {
return null;
}
}
static Future<Uint8List?> getOrFetch(String url) async {
final now = DateTime.now().millisecondsSinceEpoch;
final box = _boxOrNull;
if (box == null) return _fetchWithoutCache(url);
final cached = _freshCachedBytes(box, url, now);
if (cached != null) return cached;
return _fetchAndStore(box, url, now);
}
static void prefetchUrls(
Iterable<String> urls, {
int maxCount = 96,
}) {
final box = _boxOrNull;
if (box == null) return;
final now = DateTime.now().millisecondsSinceEpoch;
var queued = 0;
for (final url in urls) {
if (queued >= maxCount) break;
if (_freshCachedBytes(box, url, now) != null) continue;
if (_inFlight.containsKey(url)) continue;
queued += 1;
_fetchAndStore(box, url, now);
}
}
static Uint8List? _freshCachedBytes(Box box, String url, int now) {
final cached = box.get(url);
if (cached is! Map) return null;
final ts = cached['ts'];
final bytes = cached['bytes'];
if (ts is int &&
bytes is Uint8List &&
now - ts <= _staleAfter.inMilliseconds) {
return bytes;
}
return null;
}
static Future<Uint8List?> _fetchAndStore(Box box, String url, int now) {
final inFlight = _inFlight[url];
if (inFlight != null) return inFlight;
final future = _fetchAndStoreInternal(box, url, now);
_inFlight[url] = future;
future.whenComplete(() {
_inFlight.remove(url);
});
return future;
}
static Future<Uint8List?> _fetchAndStoreInternal(
Box box,
String url,
int now,
) async {
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) return null;
final bytes = response.bodyBytes;
await box.put(url, <String, dynamic>{'ts': now, 'bytes': bytes});
await _pruneIfNeeded(box);
return bytes;
} catch (_) {
return null;
}
}
static Future<Uint8List?> _fetchWithoutCache(String url) async {
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) return null;
return response.bodyBytes;
} catch (_) {
return null;
}
}
static Future<void> _pruneIfNeeded(Box box) async {
final overflow = box.length - _maxEntries;
if (overflow <= 0) return;
final entries = <MapEntry<dynamic, dynamic>>[];
for (final key in box.keys) {
entries.add(MapEntry(key, box.get(key)));
}
entries.sort((a, b) {
final ats = (a.value is Map && a.value['ts'] is int) ? a.value['ts'] as int : 0;
final bts = (b.value is Map && b.value['ts'] is int) ? b.value['ts'] as int : 0;
return ats.compareTo(bts);
});
for (var i = 0; i < overflow; i++) {
await box.delete(entries[i].key);
}
}
}

View File

@@ -0,0 +1,57 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
class HiveTileImage extends StatefulWidget {
const HiveTileImage({required this.url, super.key});
final String url;
@override
State<HiveTileImage> createState() => _HiveTileImageState();
}
class _HiveTileImageState extends State<HiveTileImage> {
Uint8List? _bytes;
String? _loadingUrl;
@override
void initState() {
super.initState();
_load(widget.url);
}
@override
void didUpdateWidget(covariant HiveTileImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.url != widget.url) {
_load(widget.url);
}
}
Future<void> _load(String url) async {
_loadingUrl = url;
final bytes = await HiveTileCache.getOrFetch(url);
if (!mounted || _loadingUrl != url) return;
if (bytes != null && bytes.isNotEmpty) {
setState(() {
_bytes = bytes;
});
}
}
@override
Widget build(BuildContext context) {
final bytes = _bytes;
if (bytes == null || bytes.isEmpty) {
return const ColoredBox(color: Color(0xFFE0E0E0));
}
return Image.memory(
bytes,
fit: BoxFit.cover,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
);
}
}