From 3da4e4dabcef0799475bd16ba499a91205fe815e Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Sun, 23 Aug 2026 12:19:35 +0800 Subject: [PATCH 01/40] build(storage): swap sqflite for sqlite_async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正從通知點開 app 時,資料庫可能被背景引擎鎖住、把已設定好的安裝當成第一次使用 Fix(en-US): fix a notification-tap cold start that locks the database and makes a configured install look like a first run --- lib/bootstrap.dart | 110 ++--- lib/core/astro/tle_store.dart | 27 +- lib/core/logging/log_store.dart | 116 +++-- lib/core/meshtastic/data/mesh_store.dart | 400 +++++++++--------- lib/core/network/etag_cache_store.dart | 174 ++++---- lib/core/network/network_usage_store.dart | 64 +-- lib/core/settings/settings_store.dart | 22 +- lib/core/storage/app_database.dart | 58 ++- pubspec.lock | 66 +-- pubspec.yaml | 11 +- test/core/astro/tle_source_test.dart | 20 +- test/core/logging/log_clean_test.dart | 9 +- test/core/logging/log_store_test.dart | 18 +- .../meshtastic/mesh_clock_defects_test.dart | 32 +- .../meshtastic/mesh_metrics_history_test.dart | 19 +- .../core/meshtastic/mesh_node_store_test.dart | 39 +- test/core/meshtastic/mesh_store_test.dart | 24 +- test/core/network/etag_binary_test.dart | 10 +- test/core/network/etag_cache_store_test.dart | 138 +++--- test/core/network/etag_interceptor_test.dart | 10 +- .../network/network_usage_store_test.dart | 17 +- test/core/settings/settings_store_test.dart | 35 +- test/core/storage/app_database_test.dart | 59 +-- test/core/storage/memory_db.dart | 23 + test/core/storage/retention_test.dart | 100 +++-- .../meshtastic/mesh_channel_names_test.dart | 14 +- .../meshtastic/mesh_chat_controller_test.dart | 15 +- .../features/meshtastic/mesh_unread_test.dart | 27 +- .../meshtastic_page_channel_test.dart | 6 +- test/shared/map/map_tile_cache_test.dart | 10 +- tool/check/storage.sh | 19 +- 31 files changed, 802 insertions(+), 890 deletions(-) create mode 100644 test/core/storage/memory_db.dart diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index dc1da99d4..f46569685 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -72,7 +72,7 @@ import 'package:flutter/foundation.dart' show LicenseEntry, LicenseEntryWithLineBreaks, LicenseRegistry; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:sqflite/sqflite.dart'; +import 'package:sqlite_async/sqlite_async.dart'; /// Initializes platform services and launches the app. /// @@ -411,22 +411,18 @@ Future bootstrap() async { /// database can't be opened the app runs without HTTP caching / accounting rather /// than failing to launch. The usage tables are created with `IF NOT EXISTS` on /// every open, so they're added to a pre-existing cache DB without a version bump. -Future<({EtagCacheStore etag, NetworkUsageStore usage, Database db})?> +/// +/// The v1→v2 migration rides a probe instead of sqflite's `version`/`onUpgrade` +/// hooks (sqlite_async has none): the v2 `CREATE TABLE IF NOT EXISTS` is +/// harmless against either shape — [EtagCacheStore.migrateToV2] is what drops +/// the legacy envelope table when its columns say v1. +Future<({EtagCacheStore etag, NetworkUsageStore usage, SqliteDatabase db})?> _openCache() async { try { final base = await getApplicationCacheDirectory(); - final db = await openDatabase( - '${base.path}/http_etag_cache.db', - version: 2, - onCreate: (db, _) => EtagCacheStore.createSchema(db), - onUpgrade: (db, oldVersion, newVersion) async { - // v1 was a gzip+json+base64 envelope — drop and rebuild for the fast - // columnar schema (one-time cold miss on upgrade). - if (oldVersion < 2) await EtagCacheStore.migrateToV2(db); - }, - ); + final db = EtagCacheStore.open(path: '${base.path}/http_etag_cache.db'); await NetworkUsageStore.createSchema(db); - await EtagCacheStore.configureConnection(db); + await EtagCacheStore.createSchema(db); final usage = NetworkUsageStore(db); return (etag: EtagCacheStore(db, usage: usage), usage: usage, db: db); } catch (error, stackTrace) { @@ -442,24 +438,32 @@ _openCache() async { /// cache is a separate file for exactly that reason, which is also what makes /// "clear cache" unable to reach any of this. See `core/storage/app_database.dart`. /// -/// Best-effort like the cache: transient launch races get three bounded retries; -/// a persistent failure means settings live only for this session rather than -/// the app refusing to launch. Every schema statement is `IF NOT EXISTS` and -/// runs on every open, so a database created by an older build picks up tables -/// added later without a version bump. -Future _openDurable() async { +/// Best-effort like the cache. The old failure mode this used to retry around — +/// a notification-tap cold start losing a race for the file against the +/// background engine awesome spins up, and degrading the session to "never been +/// configured" — is gone at the source with sqlite_async: opens run on a +/// background isolate with WAL and a built-in lock timeout (30 s default), so +/// contention waits instead of failing. What remains here is the honest +/// fallback for an open that still fails (missing parent directory, full disk, +/// first-unlock encryption state): bounded retries at launch, then the session +/// runs without persistence. Every schema statement is `IF NOT EXISTS` +/// and runs on every open, so a database created by an older build picks up +/// tables added later without a version bump. +Future _openDurable() async { const attempts = 3; Object? lastError; StackTrace? lastStackTrace; for (var attempt = 1; attempt <= attempts; attempt++) { - Database? db; try { final base = await getApplicationSupportDirectory(); - db = await openDatabase( - '${base.path}/dpip.db', - version: appDatabaseVersion, - onConfigure: (db) => _configureJournal(db, durable: true), - onCreate: (db, _) => _createDurableSchema(db), + final db = SqliteDatabase( + path: '${base.path}/dpip.db', + options: const SqliteOptions( + // WAL + busy_timeout are package defaults; FULL fsyncs every commit, + // which is what settings, the mesh conversation and the log want — + // none of it can be fetched again (the cache file relaxes to NORMAL). + synchronous: SqliteSynchronous.full, + ), ); await _createDurableSchema(db); if (attempt > 1) { @@ -469,19 +473,11 @@ Future _openDurable() async { } catch (error, stackTrace) { lastError = error; lastStackTrace = stackTrace; - if (db != null) { - try { - await db.close(); - } on Object { - // The open/schema error is the useful failure; closing a partial - // handle must not replace it or prevent the next recovery attempt. - } - } if (attempt < attempts) { Log.warning( - 'durable database attempt $attempt/$attempts failed; retrying', + 'durable database attempt $attempt/$attempts failed: $error', ); - await Future.delayed(Duration(milliseconds: 100 * attempt)); + await Future.delayed(const Duration(milliseconds: 150)); } } } @@ -490,49 +486,7 @@ Future _openDurable() async { return null; } -/// Puts a database into **WAL**, so a commit is an append rather than a -/// journal dance. -/// -/// With the default rollback journal every transaction — a buffered log flush, -/// an LRU touch, a tile batch — creates a journal file, fsyncs it, fsyncs the -/// directory, writes the pages back, then deletes the journal and fsyncs the -/// directory again: several barriers and a double write of every changed page, -/// for a handful of rows. WAL appends the new pages to one long-lived `-wal` -/// file and fsyncs that; the write-back into the database file is deferred to a -/// checkpoint that amortizes over many commits. Same durability, a fraction of -/// the IO and the flash wear. -/// -/// This has to run in `onConfigure`: it is the only sqflite callback invoked -/// outside a transaction, and `journal_mode` cannot be changed inside one. -/// The mode itself is persisted in the file header (so it only has to take -/// once), while `synchronous` is per connection and must be set on every open. -/// -/// [durable] keeps `synchronous = FULL` — the SQLite default — for `dpip.db`: -/// settings, the mesh conversation and the log cannot be fetched again, so a -/// commit there still fsyncs before it counts. The cache file relaxes to -/// `NORMAL`, where a WAL commit costs no fsync at all, because every byte in it -/// is re-downloadable by definition and lives in a directory the OS may empty -/// anyway. Both modes survive an app crash; only a power cut can cost the cache -/// its last commits. -/// -/// Best-effort, like the opens themselves: a database that will not take WAL -/// keeps working on the rollback journal. -Future _configureJournal(Database db, {required bool durable}) async { - try { - // `PRAGMA journal_mode` returns a row, which Android's `execute` rejects - // and Darwin reports as "not an error" — sqflite's helper handles both. - await db.setJournalMode('WAL'); - if (!durable) { - // A pragma still goes through [rawQuery] here for the same reason - // [EtagCacheStore.configureConnection] does. - await db.rawQuery('PRAGMA synchronous = NORMAL'); - } - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'WAL unavailable (rollback journal)'); - } -} - -Future _createDurableSchema(Database db) async { +Future _createDurableSchema(SqliteDatabase db) async { await SettingsStore.createSchema(db); await LogStore.createSchema(db); await TleStore.createSchema(db); diff --git a/lib/core/astro/tle_store.dart b/lib/core/astro/tle_store.dart index 171f00774..93d321e21 100644 --- a/lib/core/astro/tle_store.dart +++ b/lib/core/astro/tle_store.dart @@ -11,7 +11,7 @@ library; import 'package:dpip/core/logging/log.dart'; -import 'package:sqflite/sqflite.dart'; +import 'package:sqlite_async/sqlite_async.dart'; /// The table this store owns. const String tleTable = 'tle'; @@ -29,14 +29,14 @@ class TleStore { /// Null when the database would not open — the caller then falls back to the /// bundled snapshot, which is a complete answer on its own. - final Database? _db; + final SqliteDatabase? _db; /// Creates the table. Safe on every open. /// /// `CHECK (id = 0)` is the single-row constraint: there is only ever one /// current element set, and making that a schema rule beats remembering to /// delete the old one. - static Future createSchema(Database db) => db.execute( + static Future createSchema(SqliteDatabase db) => db.execute( 'CREATE TABLE IF NOT EXISTS $tleTable (' 'id INTEGER PRIMARY KEY CHECK (id = 0), ' 'text TEXT NOT NULL, ' @@ -47,8 +47,9 @@ class TleStore { final db = _db; if (db == null) return null; try { - final rows = await db.query(tleTable, limit: 1); - final row = rows.firstOrNull; + final row = await db.getOptional( + 'SELECT text, fetched_at FROM $tleTable LIMIT 1', + ); if (row == null) return null; return StoredElements( text: row['text']! as String, @@ -70,16 +71,16 @@ class TleStore { if (db == null) return; try { if (text == null) { - await db.update(tleTable, { - 'fetched_at': fetchedAt.toUtc().millisecondsSinceEpoch, - }, where: 'id = 0'); + await db.execute('UPDATE $tleTable SET fetched_at = ? WHERE id = 0', [ + fetchedAt.toUtc().millisecondsSinceEpoch, + ]); return; } - await db.insert(tleTable, { - 'id': 0, - 'text': text, - 'fetched_at': fetchedAt.toUtc().millisecondsSinceEpoch, - }, conflictAlgorithm: ConflictAlgorithm.replace); + await db.execute( + 'INSERT OR REPLACE INTO $tleTable (id, text, fetched_at) ' + 'VALUES (0, ?, ?)', + [text, fetchedAt.toUtc().millisecondsSinceEpoch], + ); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'writing elements'); } diff --git a/lib/core/logging/log_store.dart b/lib/core/logging/log_store.dart index f5523863d..a1f3a5900 100644 --- a/lib/core/logging/log_store.dart +++ b/lib/core/logging/log_store.dart @@ -21,7 +21,7 @@ library; import 'dart:async'; -import 'package:sqflite/sqflite.dart'; +import 'package:sqlite_async/sqlite_async.dart'; /// The table this store owns. const String logTable = 'logs'; @@ -78,7 +78,7 @@ class LogStore { _flushInterval = flushInterval, _flushAt = flushAt; - final Database _db; + final SqliteDatabase _db; final DateTime Function() _now; final Duration _flushInterval; @@ -106,25 +106,21 @@ class LogStore { } /// Creates the table. Safe on every open. - static Future createSchema(Database db) async { - // One batch: this re-runs on every launch (the IF NOT EXISTS is the - // migration mechanism), so every statement here is a launch-window - // platform round trip. - final batch = db.batch() - ..execute( - 'CREATE TABLE IF NOT EXISTS $logTable (' - 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' - 'time INTEGER NOT NULL, ' - 'level TEXT NOT NULL, ' - 'message TEXT NOT NULL, ' - 'error TEXT, ' - 'stack TEXT)', - ) + static Future createSchema(SqliteDatabase db) async { + // One call: this re-runs on every launch (the IF NOT EXISTS is the + // migration mechanism), so both statements ride one background-isolate + // round trip instead of two serial awaits. + await db.executeMultiple( + 'CREATE TABLE IF NOT EXISTS $logTable (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'time INTEGER NOT NULL, ' + 'level TEXT NOT NULL, ' + 'message TEXT NOT NULL, ' + 'error TEXT, ' + 'stack TEXT);' // Both the retention delete and every read are ordered by time. - ..execute( - 'CREATE INDEX IF NOT EXISTS ${logTable}_time ON $logTable(time)', - ); - await batch.commit(noResult: true); + 'CREATE INDEX IF NOT EXISTS ${logTable}_time ON $logTable(time)', + ); } /// Queues a line. Returns immediately — never touches the database. @@ -148,19 +144,17 @@ class LogStore { Future prune() async { await _enqueueDatabase(() async { try { - await _db.delete( - logTable, - where: 'time < ?', - whereArgs: [ + await _db.writeTransaction((tx) async { + await tx.execute('DELETE FROM $logTable WHERE time < ?', [ _now().toUtc().subtract(logRetention).millisecondsSinceEpoch, - ], - ); - // See [logMaxRows]: the newest lines survive whatever the clock says. - await _db.rawDelete( - 'DELETE FROM $logTable WHERE id NOT IN (' - 'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)', - [logMaxRows], - ); + ]); + // See [logMaxRows]: the newest lines survive whatever the clock says. + await tx.execute( + 'DELETE FROM $logTable WHERE id NOT IN (' + 'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)', + [logMaxRows], + ); + }); } on Object { // Reporting a logging failure through the logger is how a write loop // starts. @@ -180,31 +174,29 @@ class LogStore { _pending.clear(); await _enqueueDatabase(() async { try { - await _db.transaction((txn) async { - final insert = txn.batch(); + await _db.writeTransaction((tx) async { for (final entry in batch) { - insert.insert(logTable, { - 'time': entry.time.toUtc().millisecondsSinceEpoch, - 'level': entry.level, - 'message': entry.message, - 'error': entry.error, - 'stack': entry.stackTrace, - }); + await tx.execute( + 'INSERT INTO $logTable (time, level, message, error, stack) ' + 'VALUES (?, ?, ?, ?, ?)', + [ + entry.time.toUtc().millisecondsSinceEpoch, + entry.level, + entry.message, + entry.error, + entry.stackTrace, + ], + ); } - await insert.commit(noResult: true); - await txn.delete( - logTable, - where: 'time < ?', - whereArgs: [ - _now().toUtc().subtract(logRetention).millisecondsSinceEpoch, - ], - ); + await tx.execute('DELETE FROM $logTable WHERE time < ?', [ + _now().toUtc().subtract(logRetention).millisecondsSinceEpoch, + ]); // The count ceiling in the same transaction as the insert, so a // burst cannot outrun it. `id` rather than `time` because it is the // primary key and monotonic: a clock that steps backwards would // otherwise make the newest rows look like the oldest and delete // them. - await txn.rawDelete( + await tx.execute( 'DELETE FROM $logTable WHERE id NOT IN (' 'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)', [logMaxRows], @@ -221,13 +213,17 @@ class LogStore { Future> recent({int limit = 500, String? level}) async { await _databaseTail; try { - final rows = await _db.query( - logTable, - where: level == null ? null : 'level = ?', - whereArgs: level == null ? null : [level], - orderBy: 'time DESC, id DESC', - limit: limit, - ); + final rows = level == null + ? await _db.getAll( + 'SELECT time, level, message, error, stack FROM $logTable ' + 'ORDER BY time DESC, id DESC LIMIT ?', + [limit], + ) + : await _db.getAll( + 'SELECT time, level, message, error, stack FROM $logTable ' + 'WHERE level = ? ORDER BY time DESC, id DESC LIMIT ?', + [level, limit], + ); return [ for (final row in rows) StoredLog( @@ -251,8 +247,8 @@ class LogStore { Future count() async { await _databaseTail; try { - final rows = await _db.rawQuery('SELECT COUNT(*) AS n FROM $logTable'); - return (rows.firstOrNull?['n'] as int?) ?? 0; + final row = await _db.get('SELECT COUNT(*) AS n FROM $logTable'); + return (row['n'] as num).toInt(); } on Object { return 0; } @@ -263,7 +259,7 @@ class LogStore { _pending.clear(); await _enqueueDatabase(() async { try { - await _db.delete(logTable); + await _db.execute('DELETE FROM $logTable'); } on Object { // Nothing useful to say, and nowhere safe to say it. } diff --git a/lib/core/meshtastic/data/mesh_store.dart b/lib/core/meshtastic/data/mesh_store.dart index 538ad0b7b..9671da8a8 100644 --- a/lib/core/meshtastic/data/mesh_store.dart +++ b/lib/core/meshtastic/data/mesh_store.dart @@ -17,7 +17,7 @@ library; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/realtime/app_time.dart'; -import 'package:sqflite/sqflite.dart'; +import 'package:sqlite_async/sqlite_async.dart'; /// One line of the conversation log. class MeshStoredMessage { @@ -159,145 +159,114 @@ class MeshStore { /// How long utilization samples are kept — what the chart plots. static const Duration metricRetention = Duration(hours: 24); - final Database _db; + final SqliteDatabase _db; final DateTime Function() _now; - static Future createSchema(Database db) async { + static Future createSchema(SqliteDatabase db) async { // This re-runs on every launch (the IF NOT EXISTS is the migration - // mechanism), so every statement is a launch-window platform round trip — - // one probe for the ALTER-added columns, then everything else as a single - // batch commit, instead of ten serial awaits. + // mechanism), so every statement rides one background-isolate round trip: + // one probe per table with ALTER-added columns, then everything else in a + // single executeMultiple, instead of ten serial awaits. // One probe per table that has gained columns since it shipped. An empty // set means the table does not exist yet, so the CREATE below makes it // without them and every ALTER is needed. final existing = >{}; for (final table in _alterColumns.keys) { existing[table] = { - for (final row in await db.rawQuery('PRAGMA table_info($table)')) + for (final row in await db.getAll('PRAGMA table_info($table)')) row['name'] as String, }; } - final batch = db.batch() - // The node table the radio hands over on every connect, kept for the - // times there is no radio. A row per node, not a JSON blob in a settings - // key: 250 nodes re-serialised on every telemetry packet is exactly what - // the key-value store was bad at. - ..execute( - 'CREATE TABLE IF NOT EXISTS $_nodes (' - 'num INTEGER PRIMARY KEY NOT NULL, ' - 'name TEXT NOT NULL, ' - 'battery INTEGER, ' - 'last_heard INTEGER, ' - 'latitude REAL, ' - 'longitude REAL, ' - 'snr REAL NOT NULL DEFAULT 0, ' - 'via_mqtt INTEGER NOT NULL DEFAULT 0)', - ) - ..execute( - 'CREATE INDEX IF NOT EXISTS ${_nodes}_heard ON $_nodes(last_heard DESC)', - ) - // The channel table, for the times there is no radio to ask. - // - // A channel's *name* is only known while connected — it arrives in the - // config download and lives nowhere else. Without this table the chat - // screen fell back to the slot number the moment the radio went away, so - // a conversation the user knows as "DPIP" was labelled "CH2" whenever - // they opened the page before the radio finished configuring. The stored - // log outlives the connection; its labels have to as well. - ..execute( - 'CREATE TABLE IF NOT EXISTS $_channels (' - 'idx INTEGER PRIMARY KEY NOT NULL, ' - 'name TEXT NOT NULL)', - ) - ..execute(''' + await db.executeMultiple(''' + CREATE TABLE IF NOT EXISTS $_nodes ( + num INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + battery INTEGER, + last_heard INTEGER, + latitude REAL, + longitude REAL, + snr REAL NOT NULL DEFAULT 0, + via_mqtt INTEGER NOT NULL DEFAULT 0); + CREATE INDEX IF NOT EXISTS ${_nodes}_heard ON $_nodes(last_heard DESC); + -- The channel table, for the times there is no radio to ask. + -- + -- A channel's *name* is only known while connected — it arrives in the + -- config download and lives nowhere else. Without this table the chat + -- screen fell back to the slot number the moment the radio went away, so + -- a conversation the user knows as "DPIP" was labelled "CH2" whenever + -- they opened the page before the radio finished configuring. The stored + -- log outlives the connection; its labels have to as well. + CREATE TABLE IF NOT EXISTS $_channels ( + idx INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL); CREATE TABLE IF NOT EXISTS $_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, node INTEGER NOT NULL, channel INTEGER NOT NULL, text TEXT NOT NULL, - outgoing INTEGER NOT NULL DEFAULT 0 - ) - ''') - // Duplicate suppression as a constraint, not a scan: a reconnect replays - // packets the log may already hold, and `INSERT OR IGNORE` drops them at - // the storage layer. - ..execute( - 'CREATE UNIQUE INDEX IF NOT EXISTS ${_messages}_identity ' - 'ON $_messages (node, channel, ts, text)', - ) - // The read is always "this channel, newest first". - ..execute( - 'CREATE INDEX IF NOT EXISTS ${_messages}_channel_ts ' - 'ON $_messages (channel, ts DESC)', - ) - ..execute(''' + outgoing INTEGER NOT NULL DEFAULT 0); + -- Duplicate suppression as a constraint, not a scan: a reconnect replays + -- packets the log may already hold, and `INSERT OR IGNORE` drops them at + -- the storage layer. + CREATE UNIQUE INDEX IF NOT EXISTS ${_messages}_identity + ON $_messages (node, channel, ts, text); + -- The read is always "this channel, newest first". + CREATE INDEX IF NOT EXISTS ${_messages}_channel_ts + ON $_messages (channel, ts DESC); CREATE TABLE IF NOT EXISTS $_metrics ( ts INTEGER PRIMARY KEY, channel_util REAL, air_util REAL, - battery INTEGER - ) - ''') - // What the rest of the mesh looked like, one row per node per reading. - // - // The in-memory ring [MeshNodeStore] keeps is bounded by count, so on a - // busy mesh it holds minutes; this holds a day, which is the window in - // which "when did that node start failing" is a question anyone asks. - // The composite key makes a re-emitted reading an overwrite rather than - // a duplicate — the radio repeats a node's telemetry until it changes. - ..execute(''' + battery INTEGER); + -- What the rest of the mesh looked like, one row per node per reading. + -- + -- The in-memory ring [MeshNodeStore] keeps is bounded by count, so on a + -- busy mesh it holds minutes; this holds a day, which is the window in + -- which "when did that node start failing" is a question anyone asks. + -- The composite key makes a re-emitted reading an overwrite rather than + -- a duplicate — the radio repeats a node's telemetry until it changes. CREATE TABLE IF NOT EXISTS $_nodeMetrics ( ts INTEGER NOT NULL, node INTEGER NOT NULL, battery INTEGER, voltage REAL, snr REAL, - PRIMARY KEY (ts, node) - ) - ''') - // Both reads are "this node, over time" and "everything since T". - ..execute( - 'CREATE INDEX IF NOT EXISTS ${_nodeMetrics}_node_ts ' - 'ON $_nodeMetrics (node, ts)', - ) - // How far into each conversation the user has read — what the unread - // dots are computed against. Its own table rather than a column on - // [_channels]: that one is replaced wholesale from the radio's table - // and only holds named channels, either of which would silently reset - // read positions. - ..execute( - 'CREATE TABLE IF NOT EXISTS $_reads (' - 'channel INTEGER PRIMARY KEY NOT NULL, ' - 'last_read INTEGER NOT NULL)', - ); + PRIMARY KEY (ts, node)); + -- Both reads are "this node, over time" and "everything since T". + CREATE INDEX IF NOT EXISTS ${_nodeMetrics}_node_ts + ON $_nodeMetrics (node, ts); + -- How far into each conversation the user has read — what the unread + -- dots are computed against. Its own table rather than a column on + -- [$_channels]: that one is replaced wholesale from the radio's table + -- and only holds named channels, either of which would silently reset + -- read positions. + CREATE TABLE IF NOT EXISTS $_reads ( + channel INTEGER PRIMARY KEY NOT NULL, + last_read INTEGER NOT NULL) + '''); // Columns added after a table shipped arrive by ALTER — IF NOT EXISTS does // nothing for a table that already exists. On an installed one the probe // names what is present; on a fresh one the set is empty, so the ALTERs run - // after the CREATE below. + // after the CREATE above. for (final entry in _alterColumns.entries) { final present = existing[entry.key]!; if (present.isEmpty) continue; for (final (column, type) in entry.value) { if (present.contains(column)) continue; - batch.execute('ALTER TABLE ${entry.key} ADD COLUMN $column $type'); + await db.execute('ALTER TABLE ${entry.key} ADD COLUMN $column $type'); } } - await batch.commit(noResult: true); // A fresh install: the CREATEs above carry none of the ALTER columns, so // add them now that the tables exist. - final fresh = db.batch(); - var any = false; for (final entry in _alterColumns.entries) { if (existing[entry.key]!.isNotEmpty) continue; for (final (column, type) in entry.value) { - fresh.execute('ALTER TABLE ${entry.key} ADD COLUMN $column $type'); - any = true; + await db.execute('ALTER TABLE ${entry.key} ADD COLUMN $column $type'); } } - if (any) await fresh.commit(noResult: true); } /// Columns added to a table after it shipped — one list per table so the @@ -349,18 +318,27 @@ class MeshStore { /// Appends [message], ignoring one the log already holds. Returns whether it /// was new — the caller uses that to decide whether to notify or re-render. + /// + /// `RETURNING` answers "did this insert land" the way sqflite's + /// insert-with-ignore rowid once did: the unique identity index suppresses + /// reconnect replays, and a suppressed insert contributes no returning row. Future addMessage(MeshStoredMessage message) async { try { - final id = await _db.insert(_messages, { - 'ts': message.timestamp.millisecondsSinceEpoch, - 'received_at': _now().millisecondsSinceEpoch, - 'node': message.from, - 'channel': message.channel, - 'text': message.text, - 'outgoing': message.outgoing ? 1 : 0, - 'binary': message.binary ? 1 : 0, - }, conflictAlgorithm: ConflictAlgorithm.ignore); - return id != 0; + final result = await _db.execute( + 'INSERT OR IGNORE INTO $_messages ' + '(ts, received_at, node, channel, text, outgoing, binary) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id', + [ + message.timestamp.millisecondsSinceEpoch, + _now().millisecondsSinceEpoch, + message.from, + message.channel, + message.text, + message.outgoing ? 1 : 0, + message.binary ? 1 : 0, + ], + ); + return result.isNotEmpty; } catch (error, stackTrace) { Log.handle(error, stackTrace, 'mesh store addMessage'); return false; @@ -374,18 +352,24 @@ class MeshStore { int limit = 200, }) async { try { - final rows = await _db.query( - _messages, - where: channel == null ? null : 'channel = ?', - whereArgs: channel == null ? null : [channel], - // Arrival order, falling back to the radio's stamp for rows written - // before the column existed. Ranking incoming (radio clock) and - // outgoing (our clock) rows together by `ts` put a reply above the - // message it answered — visible only after a restart, because live - // inserts land in arrival order anyway. - orderBy: 'COALESCE(received_at, ts) DESC, id DESC', - limit: limit, - ); + final rows = channel == null + ? await _db.getAll( + 'SELECT id, ts, received_at, node, channel, text, outgoing, binary ' + 'FROM $_messages ' + // Arrival order, falling back to the radio's stamp for rows + // written before the column existed. Ranking incoming (radio + // clock) and outgoing (our clock) rows together by `ts` put a + // reply above the message it answered — visible only after a + // restart, because live inserts land in arrival order anyway. + 'ORDER BY COALESCE(received_at, ts) DESC, id DESC LIMIT ?', + [limit], + ) + : await _db.getAll( + 'SELECT id, ts, received_at, node, channel, text, outgoing, binary ' + 'FROM $_messages WHERE channel = ? ' + 'ORDER BY COALESCE(received_at, ts) DESC, id DESC LIMIT ?', + [channel, limit], + ); return [for (final row in rows) _readMessage(row)]; } catch (error, stackTrace) { Log.handle(error, stackTrace, 'mesh store messages'); @@ -397,7 +381,7 @@ class MeshStore { /// Channel → when the user last read it (ms). Missing = never read. Future> readLastReads() async { try { - final rows = await _db.query(_reads); + final rows = await _db.getAll('SELECT channel, last_read FROM $_reads'); return { for (final row in rows) row['channel']! as int: row['last_read']! as int, @@ -411,10 +395,10 @@ class MeshStore { /// Marks [channel] read up to [ts] (ms). Future writeLastRead(int channel, int ts) async { try { - await _db.insert(_reads, { - 'channel': channel, - 'last_read': ts, - }, conflictAlgorithm: ConflictAlgorithm.replace); + await _db.execute( + 'INSERT OR REPLACE INTO $_reads (channel, last_read) VALUES (?, ?)', + [channel, ts], + ); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'mesh store writeLastRead'); } @@ -428,7 +412,7 @@ class MeshStore { /// compare timestamps would be a page-open cost for two integers a channel. Future> unreadCounts() async { try { - final rows = await _db.rawQuery( + final rows = await _db.getAll( 'SELECT m.channel AS channel, COUNT(*) AS n ' 'FROM $_messages m ' 'LEFT JOIN $_reads r ON r.channel = m.channel ' @@ -446,7 +430,7 @@ class MeshStore { /// advances to when a conversation is opened. Future> newestIncomingTsByChannel() async { try { - final rows = await _db.rawQuery( + final rows = await _db.getAll( 'SELECT channel, MAX(ts) AS ts FROM $_messages ' 'WHERE outgoing = 0 GROUP BY channel', ); @@ -461,7 +445,7 @@ class MeshStore { Future> messageCountsByChannel() async { try { - final rows = await _db.rawQuery( + final rows = await _db.getAll( 'SELECT channel, COUNT(*) AS n FROM $_messages GROUP BY channel', ); return { @@ -479,9 +463,9 @@ class MeshStore { // behind, they point past a log that no longer exists — so the first // message to arrive after a clear lands *below* a cursor that outlived // its conversation and is counted as already read. - await _db.transaction((txn) async { - await txn.delete(_messages); - await txn.delete(_reads); + await _db.writeTransaction((tx) async { + await tx.execute('DELETE FROM $_messages'); + await tx.execute('DELETE FROM $_reads'); }); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'mesh store clearMessages'); @@ -492,24 +476,31 @@ class MeshStore { /// so the same telemetry can't be stored twice. Future addMetric(MeshMetricSample sample) async { try { - await _db.insert(_metrics, { - 'ts': sample.at.millisecondsSinceEpoch, - 'channel_util': sample.channelUtilization, - 'air_util': sample.airUtilTx, - 'battery': sample.batteryPercent, - 'voltage': sample.voltage, - 'nodes_total': sample.nodesTotal, - 'nodes_online': sample.nodesOnline, - 'rx_packets': sample.rxPackets, - 'tx_packets': sample.txPackets, - 'ls_rx': sample.lsRx, - 'ls_rx_bad': sample.lsRxBad, - 'ls_tx': sample.lsTx, - 'ls_rx_dupe': sample.lsRxDupe, - 'ls_tx_relay': sample.lsTxRelay, - 'ls_tx_relay_cancel': sample.lsTxRelayCancel, - 'heap_free': sample.heapFree, - }, conflictAlgorithm: ConflictAlgorithm.replace); + await _db.execute( + 'INSERT OR REPLACE INTO $_metrics ' + '(ts, channel_util, air_util, battery, voltage, nodes_total, ' + 'nodes_online, rx_packets, tx_packets, ls_rx, ls_rx_bad, ls_tx, ' + 'ls_rx_dupe, ls_tx_relay, ls_tx_relay_cancel, heap_free) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + sample.at.millisecondsSinceEpoch, + sample.channelUtilization, + sample.airUtilTx, + sample.batteryPercent, + sample.voltage, + sample.nodesTotal, + sample.nodesOnline, + sample.rxPackets, + sample.txPackets, + sample.lsRx, + sample.lsRxBad, + sample.lsTx, + sample.lsRxDupe, + sample.lsTxRelay, + sample.lsTxRelayCancel, + sample.heapFree, + ], + ); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'mesh store addMetric'); } @@ -521,8 +512,8 @@ class MeshStore { Future _windowStart(String table, Duration window, DateTime now) async { final nowMs = now.millisecondsSinceEpoch; try { - final rows = await _db.rawQuery('SELECT MAX(ts) AS newest FROM $table'); - final newest = (rows.first['newest'] as num?)?.toInt(); + final row = await _db.get('SELECT MAX(ts) AS newest FROM $table'); + final newest = (row['newest'] as num?)?.toInt(); final anchor = newest != null && newest < nowMs ? newest : nowMs; return anchor - window.inMilliseconds; } catch (error, stackTrace) { @@ -535,11 +526,12 @@ class MeshStore { Future> metrics() async { try { final since = await _windowStart(_metrics, metricRetention, _now()); - final rows = await _db.query( - _metrics, - where: 'ts >= ?', - whereArgs: [since], - orderBy: 'ts ASC', + final rows = await _db.getAll( + 'SELECT ts, channel_util, air_util, battery, voltage, nodes_total, ' + 'nodes_online, rx_packets, tx_packets, ls_rx, ls_rx_bad, ls_tx, ' + 'ls_rx_dupe, ls_tx_relay, ls_tx_relay_cancel, heap_free ' + 'FROM $_metrics WHERE ts >= ? ORDER BY ts ASC', + [since], ); return [ for (final row in rows) @@ -575,18 +567,20 @@ class MeshStore { Future addNodeMetrics(List samples) async { if (samples.isEmpty) return; try { - await _db.transaction((txn) async { - final batch = txn.batch(); + await _db.writeTransaction((tx) async { for (final sample in samples) { - batch.insert(_nodeMetrics, { - 'ts': sample.at.millisecondsSinceEpoch, - 'node': sample.node, - 'battery': sample.battery, - 'voltage': sample.voltage, - 'snr': sample.snr, - }, conflictAlgorithm: ConflictAlgorithm.replace); + await tx.execute( + 'INSERT OR REPLACE INTO $_nodeMetrics ' + '(ts, node, battery, voltage, snr) VALUES (?, ?, ?, ?, ?)', + [ + sample.at.millisecondsSinceEpoch, + sample.node, + sample.battery, + sample.voltage, + sample.snr, + ], + ); } - await batch.commit(noResult: true); }); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'mesh store addNodeMetrics'); @@ -598,12 +592,17 @@ class MeshStore { Future> nodeMetrics({int? node}) async { try { final since = await _windowStart(_nodeMetrics, metricRetention, _now()); - final rows = await _db.query( - _nodeMetrics, - where: node == null ? 'ts >= ?' : 'ts >= ? AND node = ?', - whereArgs: node == null ? [since] : [since, node], - orderBy: 'ts ASC', - ); + final rows = node == null + ? await _db.getAll( + 'SELECT ts, node, battery, voltage, snr FROM $_nodeMetrics ' + 'WHERE ts >= ? ORDER BY ts ASC', + [since], + ) + : await _db.getAll( + 'SELECT ts, node, battery, voltage, snr FROM $_nodeMetrics ' + 'WHERE ts >= ? AND node = ? ORDER BY ts ASC', + [since, node], + ); return [ for (final row in rows) MeshNodeMetricSample( @@ -628,19 +627,21 @@ class MeshStore { // On `received_at`, never on `ts` — see [_alterColumns]. A row with no // arrival time survives: it predates the column, and its true age is // unknowable. - await _db.delete( - _messages, - where: 'received_at IS NOT NULL AND received_at < ?', - whereArgs: [now.subtract(messageRetention).millisecondsSinceEpoch], + await _db.execute( + 'DELETE FROM $_messages ' + 'WHERE received_at IS NOT NULL AND received_at < ?', + [now.subtract(messageRetention).millisecondsSinceEpoch], ); // Rows written before the channel-hash guard existed can carry a hash // (242, 92, …) where an index belongs; they synthesise phantom "CH242" // conversations in the picker. The guard stops new ones — this clears // the legacy ones. Slot indices are 0–7, fixed by the firmware. - await _db.delete(_messages, where: 'channel > 7 OR channel < 0'); + await _db.execute( + 'DELETE FROM $_messages WHERE channel > 7 OR channel < 0', + ); // The same shape guard on the read cursors, which are keyed by the same // channel number and had no prune path at all. - await _db.delete(_reads, where: 'channel > 7 OR channel < 0'); + await _db.execute('DELETE FROM $_reads WHERE channel > 7 OR channel < 0'); // Measured from the newest row when that is *older* than now, not from // now alone. // @@ -657,12 +658,10 @@ class MeshStore { // accumulate — and every chart windows from *now* regardless, so a stale // day is never drawn as current. final metricCutoff = await _windowStart(_metrics, metricRetention, now); - await _db.delete(_metrics, where: 'ts < ?', whereArgs: [metricCutoff]); - await _db.delete( - _nodeMetrics, - where: 'ts < ?', - whereArgs: [await _windowStart(_nodeMetrics, metricRetention, now)], - ); + await _db.execute('DELETE FROM $_metrics WHERE ts < ?', [metricCutoff]); + await _db.execute('DELETE FROM $_nodeMetrics WHERE ts < ?', [ + await _windowStart(_nodeMetrics, metricRetention, now), + ]); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'mesh store prune'); } @@ -687,7 +686,7 @@ class MeshStore { /// arrives later. Future> readChannels() async { try { - final rows = await _db.query(_channels); + final rows = await _db.getAll('SELECT idx, name FROM $_channels'); return { for (final row in rows) row['idx']! as int: row['name']! as String, }; @@ -703,14 +702,15 @@ class MeshStore { /// merge would keep the name of a channel the user has since deleted. Future writeChannels(Map names) async { try { - await _db.transaction((txn) async { - await txn.delete(_channels); - final batch = txn.batch(); + await _db.writeTransaction((tx) async { + await tx.execute('DELETE FROM $_channels'); for (final entry in names.entries) { if (entry.value.isEmpty) continue; - batch.insert(_channels, {'idx': entry.key, 'name': entry.value}); + await tx.execute('INSERT INTO $_channels (idx, name) VALUES (?, ?)', [ + entry.key, + entry.value, + ]); } - await batch.commit(noResult: true); }); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'writing mesh channels'); @@ -723,7 +723,12 @@ class MeshStore { /// passes its own. Future>> readNodes({int limit = 5000}) async { try { - return await _db.query(_nodes, orderBy: 'last_heard DESC', limit: limit); + final rows = await _db.getAll( + 'SELECT num, name, battery, last_heard, latitude, longitude, snr, ' + 'via_mqtt, hops_away FROM $_nodes ORDER BY last_heard DESC LIMIT ?', + [limit], + ); + return [for (final row in rows) Map.of(row)]; } catch (error, stackTrace) { Log.handle(error, stackTrace, 'reading mesh nodes'); return const []; @@ -737,13 +742,26 @@ class MeshStore { /// one transaction is cheaper than reconciling deletions. Future writeNodes(List> rows) async { try { - await _db.transaction((txn) async { - await txn.delete(_nodes); - final batch = txn.batch(); + await _db.writeTransaction((tx) async { + await tx.execute('DELETE FROM $_nodes'); for (final row in rows) { - batch.insert(_nodes, row); + await tx.execute( + 'INSERT INTO $_nodes (num, name, battery, last_heard, latitude, ' + 'longitude, snr, via_mqtt, hops_away) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + row['num'], + row['name'], + row['battery'], + row['last_heard'], + row['latitude'], + row['longitude'], + row['snr'], + row['via_mqtt'], + row['hops_away'], + ], + ); } - await batch.commit(noResult: true); }); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'writing mesh nodes'); diff --git a/lib/core/network/etag_cache_store.dart b/lib/core/network/etag_cache_store.dart index f56e95b78..388c11773 100644 --- a/lib/core/network/etag_cache_store.dart +++ b/lib/core/network/etag_cache_store.dart @@ -37,7 +37,8 @@ import 'dart:isolate'; import 'dart:typed_data'; import 'package:dpip/core/network/network_usage_store.dart'; -import 'package:sqflite/sqflite.dart'; +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:sqlite_async/native.dart'; /// A cached HTTP response: the server [etag] to revalidate with, the response /// [body] (the JSON-encoded payload), its [contentType], and [size] — the wire @@ -117,16 +118,16 @@ class EtagCacheStore { // first write re-establishes it. unawaited( _db - .rawQuery('SELECT MAX(time) AS newest FROM $_table') - .then((rows) { - final newest = (rows.first['newest'] as num?)?.toInt() ?? 0; + .get('SELECT MAX(time) AS newest FROM $_table') + .then((row) { + final newest = (row['newest'] as num?)?.toInt() ?? 0; if (newest > _lastStamp) _lastStamp = newest; }) .catchError((Object _) {}), ); } - final Database _db; + final SqliteDatabase _db; /// Optional traffic accounting for binary [readBytes] hits. Callers must not /// also [NetworkUsageStore.record] those serves — misses / JSON `304`s stay @@ -191,8 +192,8 @@ class EtagCacheStore { int _writesSinceSweep = 0; /// Creates the v2 cache table (idempotent) — call from `onCreate` / migrate. - static Future createSchema(Database db) async { - await db.execute( + static Future createSchema(SqliteDatabase db) async { + await db.executeMultiple( 'CREATE TABLE IF NOT EXISTS $_table (' 'key TEXT PRIMARY KEY, ' 'etag TEXT NOT NULL, ' @@ -200,31 +201,33 @@ class EtagCacheStore { 'kind INTEGER NOT NULL, ' 'body BLOB NOT NULL, ' 'size INTEGER NOT NULL, ' - 'time INTEGER NOT NULL)', - ); - await db.execute( + 'time INTEGER NOT NULL);' 'CREATE INDEX IF NOT EXISTS ${_table}_time ON $_table(time)', ); } - /// Connection-level SQLite knobs for hot tile reads (page cache + mmap). - /// Call once after [openDatabase]. - static Future configureConnection( - Database db, { + /// Connection-level SQLite knobs for hot tile reads. + /// + /// sqlite_async opens each pooled connection in its own background isolate, + /// so per-connection PRAGMAs ride a [NativeSqliteOpenFactory] subclass whose + /// [NativeSqliteOpenFactory.pragmaStatements] runs inside every opened + /// connection — not once per database. Call instead of the plain + /// [SqliteDatabase] constructor when opening this file. + static SqliteDatabase open({ + required String path, int pageCacheKiB = defaultPageCacheKiB, - }) async { - // PRAGMAs that return a row must use [rawQuery] — on Darwin, [execute] - // treats the result as an error ("not an error") and would abort bootstrap - // into "ETag cache unavailable". - // Negative cache_size = kibibytes reserved for the pager (~25 MiB default). - await db.rawQuery('PRAGMA cache_size = -$pageCacheKiB'); - await db.rawQuery('PRAGMA mmap_size = ${64 * 1024 * 1024}'); - } + }) => SqliteDatabase.withFactory( + _CacheOpenFactory( + path: path, + sqliteOptions: const SqliteOptions(synchronous: SqliteSynchronous.normal), + pageCacheKiB: pageCacheKiB, + ), + ); /// Migrates v1 (single `value` blob envelope) → v2 columnar schema. /// Drops the old table (one-time cold miss) — simpler and safer than parsing /// every legacy row on the UI isolate. - static Future migrateToV2(Database db) async { + static Future migrateToV2(SqliteDatabase db) async { await db.execute('DROP TABLE IF EXISTS $_table'); await createSchema(db); } @@ -340,12 +343,11 @@ class EtagCacheStore { final chunk = end < urls.length ? urls.sublist(i, end) : urls.sublist(i); - final placeholders = List.filled(chunk.length, '?').join(','); rows.addAll( - await _db.query( - _table, - where: 'key IN ($placeholders)', - whereArgs: chunk, + await _db.getAll( + 'SELECT key, etag, content_type, kind, body, size FROM $_table ' + 'WHERE key IN (${List.filled(chunk.length, '?').join(',')})', + chunk, ), ); } @@ -401,16 +403,13 @@ class EtagCacheStore { /// Returns just the cached etag for [url], or null on a miss. Future readEtag(String url) async { try { - final rows = await _db.query( - _table, - columns: ['etag'], - where: 'key = ?', - whereArgs: [url], - limit: 1, + final row = await _db.getOptional( + 'SELECT etag FROM $_table WHERE key = ? LIMIT 1', + [url], ); - if (rows.isEmpty) return null; + if (row == null) return null; _scheduleTouch(url); - return rows.first['etag'] as String?; + return row['etag'] as String?; } catch (_) { return null; } @@ -473,17 +472,22 @@ class EtagCacheStore { try { final encoded = await _encodeBinaryAll(writes); final now = _lruStamp(); - await _db.transaction((txn) async { + await _db.writeTransaction((tx) async { for (final row in encoded) { - await txn.insert(_table, { - 'key': row.url, - 'etag': row.etag, - 'content_type': row.contentType, - 'kind': row.kind, - 'body': row.body, - 'size': row.size, - 'time': now, - }, conflictAlgorithm: ConflictAlgorithm.replace); + await tx.execute( + 'INSERT OR REPLACE INTO $_table ' + '(key, etag, content_type, kind, body, size, time) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + row.url, + row.etag, + row.contentType, + row.kind, + row.body, + row.size, + now, + ], + ); } }); var added = 0; @@ -519,15 +523,12 @@ class EtagCacheStore { required Uint8List body, required int size, }) async { - await _db.insert(_table, { - 'key': url, - 'etag': etag, - 'content_type': contentType, - 'kind': kind, - 'body': body, - 'size': size, - 'time': _lruStamp(), - }, conflictAlgorithm: ConflictAlgorithm.replace); + await _db.execute( + 'INSERT OR REPLACE INTO $_table ' + '(key, etag, content_type, kind, body, size, time) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?)', + [url, etag, contentType, kind, body, size, _lruStamp()], + ); await _noteWrite(body.length, 1); } @@ -545,7 +546,7 @@ class EtagCacheStore { if (tracked != null) _trackedBytes = tracked + addedBytes; if (maxBytes <= 0) { - await _db.delete(_table); + await _db.execute('DELETE FROM $_table'); _trackedBytes = 0; _writesSinceSweep = 0; return; @@ -583,7 +584,7 @@ class EtagCacheStore { // BY` is unchanged, so the `time` index still serves it without a sort. final victims = {}; for (var offset = 0; total > maxBytes; offset += _trimScanChunk) { - final rows = await _db.rawQuery( + final rows = await _db.getAll( 'SELECT key, LENGTH(body) AS b FROM $_table ' 'ORDER BY time ASC LIMIT ? OFFSET ?', [_trimScanChunk, offset], @@ -605,20 +606,19 @@ class EtagCacheStore { for (var i = 0; i < keys.length; i += _readInChunk) { final end = i + _readInChunk; final chunk = end < keys.length ? keys.sublist(i, end) : keys.sublist(i); - await _db.delete( - _table, - where: 'key IN (${List.filled(chunk.length, '?').join(',')})', - whereArgs: chunk, + await _db.execute( + 'DELETE FROM $_table WHERE key IN (${List.filled(chunk.length, '?').join(',')})', + chunk, ); } _trackedBytes = total; } Future _measureBytes() async { - final rows = await _db.rawQuery( + final row = await _db.get( 'SELECT COALESCE(SUM(LENGTH(body)), 0) AS b FROM $_table', ); - return (rows.first['b'] as num).toInt(); + return (row['b'] as num).toInt(); } /// Brings the store back inside its byte budget. @@ -643,7 +643,7 @@ class EtagCacheStore { /// Deletes every cached entry. Future clear() async { try { - await _db.delete(_table); + await _db.execute('DELETE FROM $_table'); _trackedBytes = 0; _writesSinceSweep = 0; } catch (_) {} @@ -661,10 +661,9 @@ class EtagCacheStore { /// Row count and total stored body bytes — for the Debug page. Future stats() async { try { - final rows = await _db.rawQuery( + final row = await _db.get( 'SELECT COUNT(*) AS c, COALESCE(SUM(LENGTH(body)), 0) AS b FROM $_table', ); - final row = rows.first; return ( rows: (row['c'] as num).toInt(), bytes: (row['b'] as num).toInt(), @@ -675,14 +674,13 @@ class EtagCacheStore { } Future?> _queryRow(String url) async { - final rows = await _db.query( - _table, - where: 'key = ?', - whereArgs: [url], - limit: 1, + final row = await _db.getOptional( + 'SELECT key, etag, content_type, kind, body, size FROM $_table ' + 'WHERE key = ? LIMIT 1', + [url], ); - if (rows.isEmpty) return null; - return rows.first; + if (row == null) return null; + return Map.of(row); } /// JSON bodies are stored gzip-1; inflate off the UI isolate when large. @@ -903,15 +901,14 @@ class EtagCacheStore { _pendingTouch.clear(); final now = _lruStamp(); try { - await _db.transaction((txn) async { + await _db.writeTransaction((tx) async { for (var i = 0; i < urls.length; i += _touchInChunk) { final end = i + _touchInChunk; final chunk = end < urls.length ? urls.sublist(i, end) : urls.sublist(i); - final placeholders = List.filled(chunk.length, '?').join(','); - await txn.rawUpdate( - 'UPDATE $_table SET time = ? WHERE key IN ($placeholders)', + await tx.execute( + 'UPDATE $_table SET time = ? WHERE key IN (${List.filled(chunk.length, '?').join(',')})', [now, ...chunk], ); } @@ -919,3 +916,26 @@ class EtagCacheStore { } catch (_) {} } } + +/// Pool factory for the cache file — adds the hot-read PRAGMAs to **every** +/// connection sqlite_async opens (one writer plus up to [SqliteOptions.maxReaders] +/// readers), which is where they belong: `cache_size` and `mmap_size` are +/// per-connection settings, and a tile burst reads through whichever pooled +/// reader picks it up. +base class _CacheOpenFactory extends NativeSqliteOpenFactory { + _CacheOpenFactory({ + required super.path, + required super.sqliteOptions, + required this.pageCacheKiB, + }); + + final int pageCacheKiB; + + @override + List pragmaStatements(covariant SqliteOpenOptions options) => [ + ...super.pragmaStatements(options), + // Negative cache_size = kibibytes reserved for the pager (~25 MiB). + 'PRAGMA cache_size = -$pageCacheKiB', + 'PRAGMA mmap_size = ${64 * 1024 * 1024}', + ]; +} diff --git a/lib/core/network/network_usage_store.dart b/lib/core/network/network_usage_store.dart index e550168bf..aae89e9d3 100644 --- a/lib/core/network/network_usage_store.dart +++ b/lib/core/network/network_usage_store.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:sqflite/sqflite.dart'; +import 'package:sqlite_async/sqlite_async.dart'; /// A snapshot of network usage for the Debug page. /// @@ -101,7 +101,7 @@ class NetworkUsageStore { this.flushEvery = 64, }) : _now = now ?? DateTime.now; - final Database _db; + final SqliteDatabase _db; /// Injectable clock — the wall time used to bucket and window usage. final DateTime Function() _now; @@ -129,7 +129,7 @@ class NetworkUsageStore { /// Creates the usage table (idempotent) — call on database open. Uses /// `IF NOT EXISTS` so it also adds the table to a pre-existing cache database /// without a version bump, then migrates an older shape in place. - static Future createSchema(Database db) async { + static Future createSchema(SqliteDatabase db) async { final defs = _columns .map((c) => '$c INTEGER NOT NULL DEFAULT 0') .join(', '); @@ -143,10 +143,10 @@ class NetworkUsageStore { /// /// [createSchema] runs on every open with `IF NOT EXISTS`, so an installed /// database never picks up new columns on its own. - static Future _migrate(Database db) async { + static Future _migrate(SqliteDatabase db) async { try { final existing = { - for (final row in await db.rawQuery('PRAGMA table_info($_buckets)')) + for (final row in await db.getAll('PRAGMA table_info($_buckets)')) row['name'] as String, }; for (final column in _columns) { @@ -225,15 +225,13 @@ class NetworkUsageStore { try { final hour = _now().millisecondsSinceEpoch ~/ _hourMs; - await _db.transaction((txn) async { + await _db.writeTransaction((tx) async { for (final entry in pending.entries) { - await _addToBucket(txn, entry.key, entry.value); + await _addToBucket(tx, entry.key, entry.value); } - await txn.delete( - _buckets, - where: 'hour < ?', - whereArgs: [hour - _windowHours], - ); + await tx.execute('DELETE FROM $_buckets WHERE hour < ?', [ + hour - _windowHours, + ]); }); } catch (_) { // Accounting is diagnostic-only; never surface a failure. @@ -250,11 +248,9 @@ class NetworkUsageStore { Future prune() async { try { final hour = _now().millisecondsSinceEpoch ~/ _hourMs; - await _db.delete( - _buckets, - where: 'hour < ?', - whereArgs: [hour - _windowHours], - ); + await _db.execute('DELETE FROM $_buckets WHERE hour < ?', [ + hour - _windowHours, + ]); } catch (_) { // Accounting is diagnostic-only; never surface a failure. } @@ -271,7 +267,7 @@ class NetworkUsageStore { _pendingByHour.clear(); _pendingEvents = 0; try { - await _db.delete(_buckets); + await _db.execute('DELETE FROM $_buckets'); } catch (_) { // Accounting is diagnostic-only; never surface a failure. } @@ -315,7 +311,7 @@ class NetworkUsageStore { final hour = _now().millisecondsSinceEpoch ~/ _hourMs; final bucket = hour ~/ bucketHours; final count = hours ~/ bucketHours; - final rows = await _db.rawQuery( + final rows = await _db.getAll( 'SELECT hour / ? AS bucket, ' 'COALESCE(SUM(down), 0) AS down, ' 'COALESCE(SUM(saved), 0) AS saved, ' @@ -326,7 +322,8 @@ class NetworkUsageStore { [bucketHours, (bucket - count + 1) * bucketHours], ); final byBucket = >{ - for (final row in rows) row['bucket'] as int: row, + for (final row in rows) + row['bucket'] as int: Map.of(row), }; return [ for (var b = bucket - count + 1; b <= bucket; b++) @@ -346,29 +343,32 @@ class NetworkUsageStore { static int _counter(Object? value) => (value as num?)?.toInt() ?? 0; // Update-then-insert instead of UPSERT, so it works on any bundled SQLite. - Future _addToBucket(DatabaseExecutor db, int hour, _Pending add) async { + Future _addToBucket( + SqliteWriteContext tx, + int hour, + _Pending add, + ) async { final sets = _columns.map((c) => '$c = $c + ?').join(', '); final values = [add.down, add.saved, add.hits, add.misses]; - final updated = await db.rawUpdate( + final result = await tx.execute( 'UPDATE $_buckets SET $sets WHERE hour = ?', [...values, hour], ); - if (updated == 0) { - await db.insert(_buckets, { - 'hour': hour, - for (var i = 0; i < _columns.length; i++) _columns[i]: values[i], - }); + if (result.isEmpty) { + await tx.execute( + 'INSERT INTO $_buckets (hour, ${_columns.join(', ')}) ' + 'VALUES (?, ?, ?, ?, ?)', + [hour, ...values], + ); } } /// Sums every counter over one trailing window in a single query. Future<_Pending> _sumSince(int sinceHour) async { final sums = _columns.map((c) => 'COALESCE(SUM($c), 0) AS $c').join(', '); - final rows = await _db.rawQuery( - 'SELECT $sums FROM $_buckets WHERE hour >= ?', - [sinceHour], - ); - final row = rows.first; + final row = await _db.get('SELECT $sums FROM $_buckets WHERE hour >= ?', [ + sinceHour, + ]); return _Pending() ..down = (row['down'] as num).toInt() ..saved = (row['saved'] as num).toInt() diff --git a/lib/core/settings/settings_store.dart b/lib/core/settings/settings_store.dart index 2c4337661..6e04d9ce8 100644 --- a/lib/core/settings/settings_store.dart +++ b/lib/core/settings/settings_store.dart @@ -23,7 +23,7 @@ import 'dart:convert'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/settings/setting_keys.dart'; -import 'package:sqflite/sqflite.dart'; +import 'package:sqlite_async/sqlite_async.dart'; /// The table this store owns. Named here so `app_database.dart` can document /// the layout and the storage gate can check nothing else writes it. @@ -36,20 +36,20 @@ final class SettingsStore { /// The database, or null when it could not be opened — the app then runs /// with settings that live only for this session rather than not at all. - final Database? _db; + SqliteDatabase? _db; /// The whole table, in memory. final Map _values; /// Creates the table. Safe to call on every open. - static Future createSchema(Database db) => db.execute( + static Future createSchema(SqliteDatabase db) => db.execute( 'CREATE TABLE IF NOT EXISTS $settingsTable (' 'key TEXT PRIMARY KEY NOT NULL, ' 'value TEXT NOT NULL)', ); /// Loads every row into memory. - static Future open(Database? db) async { + static Future open(SqliteDatabase? db) async { if (db == null) return SettingsStore._(null, {}); Object? lastError; @@ -57,7 +57,9 @@ final class SettingsStore { for (var attempt = 1; attempt <= _loadAttempts; attempt++) { try { final values = {}; - for (final row in await db.query(settingsTable)) { + for (final row in await db.getAll( + 'SELECT key, value FROM $settingsTable', + )) { final key = row['key'] as String?; final value = row['value'] as String?; if (key == null || value == null) continue; @@ -125,7 +127,7 @@ final class SettingsStore { final db = _db; if (db == null) return; try { - await db.delete(settingsTable, where: 'key = ?', whereArgs: [key.name]); + await db.execute('DELETE FROM $settingsTable WHERE key = ?', [key.name]); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'removing setting ${key.name}'); } @@ -140,10 +142,10 @@ final class SettingsStore { final db = _db; if (db == null) return; try { - await db.insert(settingsTable, { - 'key': key.name, - 'value': jsonEncode(value), - }, conflictAlgorithm: ConflictAlgorithm.replace); + await db.execute( + 'INSERT OR REPLACE INTO $settingsTable (key, value) VALUES (?, ?)', + [key.name, jsonEncode(value)], + ); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'writing setting ${key.name}'); } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index ea1c2b30c..3487108d9 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -33,7 +33,7 @@ library; import 'package:dpip/core/logging/log.dart'; -import 'package:sqflite/sqflite.dart'; +import 'package:sqlite_async/sqlite_async.dart'; /// Schema version of the durable database. const int appDatabaseVersion = 1; @@ -48,26 +48,37 @@ class AppDatabase { const AppDatabase({required this.durable, required this.cache}); /// Settings, orbital elements and mesh history. Survives a cache purge. - final Database? durable; + final SqliteDatabase? durable; /// Re-fetchable bytes only. - final Database? cache; + final SqliteDatabase? cache; /// Empties every cache table, and nothing else. /// /// It takes the cache handle and no other, so there is no path from here to /// the settings or the mesh log even by accident. Returns the number of rows /// dropped, which is what a settings screen wants to show. + /// + /// One transaction: the per-table counts come from SQLite's `changes()` + /// read on the same write connection as the delete — outside one, + /// sqlite_async's pooled readers would answer from a different connection + /// where nothing had changed. Future clearCache() async { final database = cache; if (database == null) return 0; var removed = 0; - for (final table in cacheTables) { - try { - removed += await database.delete(table); - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'clearing $table'); - } + try { + removed = await database.writeTransaction((tx) async { + var dropped = 0; + for (final table in cacheTables) { + await tx.execute('DELETE FROM $table'); + final row = await tx.get('SELECT changes() AS n'); + dropped += ((row['n'] as num?) ?? 0).toInt(); + } + return dropped; + }); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'clearing cache tables'); } // Reclaim the file space rather than leaving it as free pages: the point // of clearing a 350 MB cache is to get the storage back. @@ -83,11 +94,11 @@ class AppDatabase { Future cacheBytes() async { final database = cache; if (database == null) return 0; - final rows = await database.rawQuery( + final row = await database.get( 'SELECT page_count * page_size AS bytes ' 'FROM pragma_page_count(), pragma_page_size()', ); - return (rows.firstOrNull?['bytes'] as int?) ?? 0; + return ((row['bytes'] as num?) ?? 0).toInt(); } /// Row count and size of every table in both files, biggest first. @@ -100,10 +111,13 @@ class AppDatabase { ...await _statsFor(cache, 'http_etag_cache.db'), ]..sort((a, b) => b.bytes.compareTo(a.bytes)); - static Future> _statsFor(Database? db, String file) async { + static Future> _statsFor( + SqliteDatabase? db, + String file, + ) async { if (db == null) return const []; try { - final names = await db.rawQuery( + final names = await db.getAll( "SELECT name FROM sqlite_master WHERE type = 'table' " "AND name NOT LIKE 'sqlite_%' ORDER BY name", ); @@ -130,13 +144,13 @@ class AppDatabase { } /// On-disk bytes per table from `dbstat`, or null where it is unavailable. - static Future?> _pageSizes(Database db) async { + static Future?> _pageSizes(SqliteDatabase db) async { try { // Joined to `sqlite_master` so an index's pages land on the table it // belongs to. Grouping by `dbstat.name` alone lists indexes as if they // were tables and leaves every table looking smaller than it is — on a // message log with two indexes, most of the cost would be invisible. - final rows = await db.rawQuery( + final rows = await db.getAll( 'SELECT COALESCE(m.tbl_name, d.name) AS tbl, SUM(d.pgsize) AS bytes ' 'FROM dbstat d LEFT JOIN sqlite_master m ON m.name = d.name ' 'GROUP BY tbl', @@ -150,9 +164,9 @@ class AppDatabase { } } - static Future _countRows(Database db, String table) async { - final rows = await db.rawQuery('SELECT COUNT(*) AS n FROM "$table"'); - return (rows.firstOrNull?['n'] as num?)?.toInt() ?? 0; + static Future _countRows(SqliteDatabase db, String table) async { + final row = await db.get('SELECT COUNT(*) AS n FROM "$table"'); + return ((row['n'] as num?) ?? 0).toInt(); } /// Stored payload of a table: the length of every value in every row. @@ -160,18 +174,18 @@ class AppDatabase { /// The fallback when `dbstat` is missing. It undercounts — page overhead, /// free space and indexes are invisible to it — which is why [TableStat] /// carries [TableStat.onDisk] rather than letting the two be confused. - static Future _payloadBytes(Database db, String table) async { - final columns = await db.rawQuery('PRAGMA table_info("$table")'); + static Future _payloadBytes(SqliteDatabase db, String table) async { + final columns = await db.getAll('PRAGMA table_info("$table")'); final names = [ for (final column in columns) if (column['name'] case final String name) name, ]; if (names.isEmpty) return 0; final sum = names.map((name) => 'COALESCE(LENGTH("$name"), 0)').join(' + '); - final rows = await db.rawQuery( + final row = await db.get( 'SELECT COALESCE(SUM($sum), 0) AS bytes FROM "$table"', ); - return (rows.firstOrNull?['bytes'] as num?)?.toInt() ?? 0; + return ((row['bytes'] as num?) ?? 0).toInt(); } } diff --git a/pubspec.lock b/pubspec.lock index 409242a02..cc065ca59 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1107,62 +1107,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" - sqflite: - dependency: "direct main" - description: - name: sqflite - sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b - url: "https://pub.dev" - source: hosted - version: "2.4.3" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" - url: "https://pub.dev" - source: hosted - version: "2.5.11" - sqflite_common_ffi: + sqlite3: dependency: "direct dev" description: - name: sqflite_common_ffi - sha256: "5ccd38136edb9beb3213f6927775d52db70dfdadcdb28dad1f625ca9f2b9824f" + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" url: "https://pub.dev" source: hosted - version: "2.4.2" - sqflite_darwin: + version: "3.5.2" + sqlite3_connection_pool: dependency: transitive description: - name: sqflite_darwin - sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + name: sqlite3_connection_pool + sha256: "8f2df36dc9f0f51ec04506b90848769b5a4538a9f937472147a274910a92e7ee" url: "https://pub.dev" source: hosted - version: "2.4.3+1" - sqflite_platform_interface: + version: "0.2.9" + sqlite3_web: dependency: transitive description: - name: sqflite_platform_interface - sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + name: sqlite3_web + sha256: aa6af15ef8bf8551d3a84203e3cbc022990567372e46ef98f91bdb2018fcfb0e url: "https://pub.dev" source: hosted - version: "2.4.1" - sqlite3: - dependency: transitive + version: "0.9.4" + sqlite_async: + dependency: "direct main" description: - name: sqlite3 - sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478" + name: sqlite_async + sha256: "83aff15d2bb3b296d35e15419a85c3207df3baca62b64b778958a59eb291502d" url: "https://pub.dev" source: hosted - version: "3.5.1" + version: "0.14.4" stack_trace: dependency: transitive description: @@ -1195,14 +1171,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" - url: "https://pub.dev" - source: hosted - version: "3.4.1+1" talker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6313e123d..a52154157 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,7 +86,11 @@ dependencies: package_info_plus: ^10.2.0 path_provider: ^2.1.6 provider: ^6.1.5+1 - sqflite: ^2.4.3 + # SQLite access, all files, via one connection pool per file. Replaces + # sqflite: every operation runs on a background isolate through sqlite3 FFI, + # so a cold-start lock contention window no longer fails opens on the UI + # thread, and WAL + busy_timeout (lockTimeout) ship as sane defaults. + sqlite_async: ^0.14.4 talker_flutter: ^5.1.9 url_launcher: ^6.3.2 @@ -94,6 +98,10 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + # The synchronous SQLite API, for tests only: the in-memory helper + # (test/core/storage/memory_db.dart) wraps one open handle with + # SqliteDatabase.singleConnection so `:memory:` means one database. + sqlite3: ^3.5.2 # Virtual time for timer-driven state machines (the traceroute timeout). fake_async: ^1.3.0 # Dart 3.13 (Flutter 3.47) makes `final` illegal on non-primary-constructor @@ -103,7 +111,6 @@ dev_dependencies: build_runner: ^2.16.0 freezed: ^4.0.0-dev.3 json_serializable: ^6.14.1 - sqflite_common_ffi: ^2.4.2 url_launcher_platform_interface: ^2.3.2 dependency_overrides: diff --git a/test/core/astro/tle_source_test.dart b/test/core/astro/tle_source_test.dart index 6c2e2d229..6d0d1f367 100644 --- a/test/core/astro/tle_source_test.dart +++ b/test/core/astro/tle_source_test.dart @@ -12,7 +12,8 @@ import 'package:dpip/core/astro/satellite.dart'; import 'package:dpip/core/astro/tle_source.dart'; import 'package:dpip/core/astro/tle_store.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import '../storage/memory_db.dart'; /// The ISS on day 226 of 2026. const _older = ''' @@ -44,21 +45,9 @@ class _Never implements TleSource { } /// A real `tle` table in memory, so the test exercises the schema rather than -/// a stand-in for it. -/// A fresh in-memory database per call. -/// -/// `singleInstance: false` matters: sqflite hands back the *same* handle for a -/// repeated path, and `:memory:` is a path — so without it every test in the -/// file shares one database and the second test starts with the first one's -/// rows. That is exactly the kind of shared state that makes a suite pass in -/// isolation and fail as a group. -Future _openMemory() => databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), -); - +/// a stand-in for it. A fresh database per call — see [openMemoryDb]. Future _store({String? seed, DateTime? fetchedAt}) async { - final db = await _openMemory(); + final db = openMemoryDb(); await TleStore.createSchema(db); final store = TleStore(db); if (seed != null) { @@ -69,7 +58,6 @@ Future _store({String? seed, DateTime? fetchedAt}) async { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - sqfliteFfiInit(); var clock = DateTime.utc(2026, 8, 20); diff --git a/test/core/logging/log_clean_test.dart b/test/core/logging/log_clean_test.dart index aef825ea0..c7584816f 100644 --- a/test/core/logging/log_clean_test.dart +++ b/test/core/logging/log_clean_test.dart @@ -6,16 +6,17 @@ library; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/logging/log_store.dart'; +import '../storage/memory_db.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - sqfliteFfiInit(); - late Database db; + late SqliteDatabase db; late LogStore store; // Pinned, and handed to the store, because `flush` prunes anything older // than [logRetention] in the same transaction as the insert. With the real @@ -25,7 +26,7 @@ void main() { final clock = DateTime.utc(2026, 8, 18, 12); setUp(() async { - db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + db = openMemoryDb(); await LogStore.createSchema(db); store = LogStore(db, now: () => clock); Log.store = store; diff --git a/test/core/logging/log_store_test.dart b/test/core/logging/log_store_test.dart index fefa9c1ad..ffa259870 100644 --- a/test/core/logging/log_store_test.dart +++ b/test/core/logging/log_store_test.dart @@ -10,23 +10,17 @@ library; import 'package:dpip/core/logging/log_store.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; -/// A fresh database per call — sqflite hands back the same handle for a -/// repeated path, and `:memory:` is a path. -Future _openMemory() => databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), -); +import '../storage/memory_db.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - sqfliteFfiInit(); var clock = DateTime.utc(2026, 8, 15, 12); - Future<(LogStore, Database)> makeStore({int flushAt = 64}) async { - final db = await _openMemory(); + Future<(LogStore, SqliteDatabase)> makeStore({int flushAt = 64}) async { + final db = openMemoryDb(); await LogStore.createSchema(db); return (LogStore(db, now: () => clock, flushAt: flushAt), db); } @@ -110,8 +104,8 @@ void main() { store.add(line('line $i', at: clock.add(Duration(seconds: i)))); } await store.flush(); - final rows = await db.rawQuery('SELECT COUNT(*) AS n FROM $logTable'); - expect(rows.single['n'], logMaxRows); + final row = await db.get('SELECT COUNT(*) AS n FROM $logTable'); + expect(row['n'], logMaxRows); }); test('the ceiling keeps the newest lines, not the oldest', () async { diff --git a/test/core/meshtastic/mesh_clock_defects_test.dart b/test/core/meshtastic/mesh_clock_defects_test.dart index e1bb9323a..f23eb1af8 100644 --- a/test/core/meshtastic/mesh_clock_defects_test.dart +++ b/test/core/meshtastic/mesh_clock_defects_test.dart @@ -10,20 +10,18 @@ library; import 'package:dpip/core/meshtastic/data/mesh_store.dart'; import 'package:dpip/core/meshtastic/data/meshtastic_client_impl.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; -Future _open() async { - final db = await databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), - ); +import '../storage/memory_db.dart'; + +Future _open() async { + final db = openMemoryDb(); await MeshStore.createSchema(db); return db; } void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(sqfliteFfiInit); final now = DateTime.utc(2026, 8, 16, 12); @@ -76,15 +74,19 @@ void main() { addTearDown(db.close); // No `received_at`: its true arrival time is unknowable, and deleting on // a guess is the failure the column exists to stop. - await db.insert('mesh_messages', { - 'ts': now.subtract(const Duration(days: 365)).millisecondsSinceEpoch, - 'node': 7, - 'channel': 0, - 'text': 'legacy', - 'outgoing': 0, - }); + await db.execute( + 'INSERT INTO mesh_messages (ts, node, channel, text, outgoing) ' + 'VALUES (?, ?, ?, ?, ?)', + [ + now.subtract(const Duration(days: 365)).millisecondsSinceEpoch, + 7, + 0, + 'legacy', + 0, + ], + ); await MeshStore(db, now: () => now).prune(); - expect(await db.query('mesh_messages'), hasLength(1)); + expect(await db.getAll('SELECT * FROM mesh_messages'), hasLength(1)); }); }); diff --git a/test/core/meshtastic/mesh_metrics_history_test.dart b/test/core/meshtastic/mesh_metrics_history_test.dart index bbb9d89ea..387b29e5c 100644 --- a/test/core/meshtastic/mesh_metrics_history_test.dart +++ b/test/core/meshtastic/mesh_metrics_history_test.dart @@ -9,13 +9,12 @@ library; import 'package:dpip/core/meshtastic/data/mesh_store.dart'; import 'package:dpip/core/storage/app_database.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; -Future _open() async { - final db = await databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), - ); +import '../storage/memory_db.dart'; + +Future _open() async { + final db = openMemoryDb(); await MeshStore.createSchema(db); return db; } @@ -25,7 +24,6 @@ DateTime _ago(Duration d) => _now.subtract(d); void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(sqfliteFfiInit); group('the radio', () { test('keeps every series it was given', () async { @@ -62,12 +60,9 @@ void main() { // The durable schema is re-applied on every open as CREATE TABLE IF NOT // EXISTS, which does nothing for a table that is already there — so a // new column has to arrive by ALTER or it never appears at all. - final db = await databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), - ); + final db = openMemoryDb(); addTearDown(db.close); - await db.execute(''' + await db.executeMultiple(''' CREATE TABLE mesh_metrics ( ts INTEGER PRIMARY KEY, channel_util REAL, diff --git a/test/core/meshtastic/mesh_node_store_test.dart b/test/core/meshtastic/mesh_node_store_test.dart index 9c5090b2c..5ccd9237d 100644 --- a/test/core/meshtastic/mesh_node_store_test.dart +++ b/test/core/meshtastic/mesh_node_store_test.dart @@ -4,13 +4,13 @@ import 'package:dpip/core/meshtastic/mesh_node_store.dart'; import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; import 'fake_mesh_service.dart'; +import '../storage/memory_db.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - sqfliteFfiInit(); var clock = DateTime.utc(2026, 1, 1, 12); @@ -38,18 +38,14 @@ void main() { /// Nodes now live in the `mesh_nodes` table, so a restart test needs the /// *same* database on the second open — hence the explicit handle rather - /// than a fresh `:memory:` each time (sqflite would hand back a shared one - /// anyway, which is worse: silent cross-test state). - Future memoryDb() => databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), - ); + /// than a fresh `:memory:` each time. + SqliteDatabase memoryDb() => openMemoryDb(); late SettingsStore settings; Future<(MeshNodeStore, FakeMeshService)> makeStore({ Map initial = const {}, - Database? db, + SqliteDatabase? db, }) async { clock = DateTime.utc(2026, 1, 1, 12); final service = FakeMeshService(); @@ -109,7 +105,7 @@ void main() { }); test('survives a restart, with freshness recomputed', () async { - final db = await memoryDb(); + final db = memoryDb(); final (store, service) = await makeStore(db: db); service.nodes.add( node(7, name: 'repeater', lat: 23.5, lon: 120.5, heard: clock), @@ -142,7 +138,7 @@ void main() { }); test('drops the least recently heard past the cap', () async { - final db = await memoryDb(); + final db = memoryDb(); final (store, service) = await makeStore(db: db); for (var i = 0; i < MeshNodeStore.maxNodes + 5; i++) { service.nodes.add(node(i, heard: clock.subtract(Duration(seconds: i)))); @@ -216,7 +212,7 @@ void main() { ); test('the MQTT flag survives a restart', () async { - final db = await memoryDb(); + final db = memoryDb(); final (store, service) = await makeStore(db: db); service.nodes.add(node(2, lat: 35.6, lon: 139.7, viaMqtt: true)); await flush(); @@ -232,19 +228,22 @@ void main() { // whole list to one malformed entry. Columns with NOT NULL make both // states unrepresentable — the row is rejected at write time instead of // being discovered at read time. - final db = await memoryDb(); + final db = memoryDb(); await MeshStore.createSchema(db); // A node with no name is rejected at write time. await expectLater( - db.insert('mesh_nodes', {'num': 5, 'snr': 0.0}), + db.execute('INSERT INTO mesh_nodes (num, snr) VALUES (5, 0.0)'), throwsA(isA()), ); // A node always has a number: `num INTEGER PRIMARY KEY` is the rowid, so // one is assigned even when the caller omits it. There is no such thing // as the numberless entry the JSON blob could produce. - final id = await db.insert('mesh_nodes', {'name': 'auto', 'snr': 0.0}); - expect(id, greaterThan(0)); - await db.delete('mesh_nodes'); + final id = await db.execute( + "INSERT INTO mesh_nodes (name, snr) VALUES ('auto', 0.0) " + 'RETURNING num', + ); + expect((id.first['num'] as int), greaterThan(0)); + await db.execute('DELETE FROM mesh_nodes'); // And a well-formed row round-trips. final (store, _) = await makeStore(db: db); await MeshStore(db).writeNodes([ @@ -256,7 +255,7 @@ void main() { }); test('clear empties the table and its storage', () async { - final db = await memoryDb(); + final db = memoryDb(); final (store, service) = await makeStore(db: db); service.nodes.add(node(1)); await flush(); @@ -345,7 +344,7 @@ void main() { group('hop distance', () { test('an unknown distance stays null, never 0', () async { - final db = await memoryDb(); + final db = memoryDb(); addTearDown(db.close); await MeshStore.createSchema(db); await MeshStore(db).writeNodes([ @@ -386,7 +385,7 @@ void main() { ); test('round-trips through the table', () async { - final db = await memoryDb(); + final db = memoryDb(); addTearDown(db.close); final (first, service) = await makeStore(db: db); service.nodes.add(node(9, name: 'via two', hops: 2)); diff --git a/test/core/meshtastic/mesh_store_test.dart b/test/core/meshtastic/mesh_store_test.dart index 67a03bf2a..7a7ce76fe 100644 --- a/test/core/meshtastic/mesh_store_test.dart +++ b/test/core/meshtastic/mesh_store_test.dart @@ -1,16 +1,16 @@ import 'package:dpip/core/meshtastic/data/mesh_store.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import '../storage/memory_db.dart'; void main() { - late Database db; + late SqliteDatabase db; var clock = DateTime.utc(2026, 1, 10, 12); - setUpAll(sqfliteFfiInit); - setUp(() async { clock = DateTime.utc(2026, 1, 10, 12); - db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + db = openMemoryDb(); await MeshStore.createSchema(db); }); @@ -204,7 +204,7 @@ void main() { // Simulated by putting the table back into its pre-column shape and // re-running createSchema, which is exactly what an upgrade does. await db.execute('DROP TABLE mesh_messages'); - await db.execute(''' + await db.executeMultiple(''' CREATE TABLE mesh_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, @@ -214,13 +214,11 @@ void main() { outgoing INTEGER NOT NULL DEFAULT 0 ) '''); - await db.insert('mesh_messages', { - 'ts': clock.millisecondsSinceEpoch, - 'node': 1, - 'channel': 0, - 'text': 'from before the column', - 'outgoing': 0, - }); + await db.execute( + 'INSERT INTO mesh_messages (ts, node, channel, text, outgoing) ' + 'VALUES (?, ?, ?, ?, ?)', + [clock.millisecondsSinceEpoch, 1, 0, 'from before the column', 0], + ); await MeshStore.createSchema(db); final rows = await store().messages(channel: 0); diff --git a/test/core/network/etag_binary_test.dart b/test/core/network/etag_binary_test.dart index 34dfaa471..fbc86123e 100644 --- a/test/core/network/etag_binary_test.dart +++ b/test/core/network/etag_binary_test.dart @@ -6,7 +6,9 @@ import 'package:dpip/core/network/dio_client.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/etag_interceptor.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import '../storage/memory_db.dart'; /// Answers `304` when `If-None-Match` matches, else `200` with raw [bytes]. class _BinaryAdapter implements HttpClientAdapter { @@ -41,13 +43,11 @@ class _BinaryAdapter implements HttpClientAdapter { } void main() { - late Database db; + late SqliteDatabase db; late EtagCacheStore store; - setUpAll(sqfliteFfiInit); - setUp(() async { - db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + db = openMemoryDb(); await EtagCacheStore.createSchema(db); store = EtagCacheStore(db); }); diff --git a/test/core/network/etag_cache_store_test.dart b/test/core/network/etag_cache_store_test.dart index a9ea38107..8d888c070 100644 --- a/test/core/network/etag_cache_store_test.dart +++ b/test/core/network/etag_cache_store_test.dart @@ -4,16 +4,16 @@ import 'dart:typed_data'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import '../storage/memory_db.dart'; void main() { - late Database db; + late SqliteDatabase db; late EtagCacheStore store; - setUpAll(sqfliteFfiInit); - setUp(() async { - db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + db = openMemoryDb(); await EtagCacheStore.createSchema(db); await NetworkUsageStore.createSchema(db); store = EtagCacheStore(db); @@ -21,6 +21,20 @@ void main() { tearDown(() async => db.close()); + Future setTime(String url, int time) => + db.execute('UPDATE http_cache SET time = ? WHERE key = ?', [time, url]); + + Future> rowFor( + String url, { + String columns = '*', + }) async { + final rows = await db.getAll( + 'SELECT $columns FROM http_cache WHERE key = ?', + [url], + ); + return Map.of(rows.first); + } + test('read on an empty cache is a miss', () async { expect(await store.read('https://x/a'), isNull); expect(await store.readEtag('https://x/a'), isNull); @@ -47,20 +61,15 @@ void main() { final entry = await store.read('https://x/a'); expect(entry!.etag, '2'); expect(entry.body, 'B'); - final rows = await db.rawQuery('SELECT COUNT(*) AS c FROM http_cache'); - expect(rows.first['c'], 1, reason: 'replaced, not duplicated'); + final row = await db.get('SELECT COUNT(*) AS c FROM http_cache'); + expect(row['c'], 1, reason: 'replaced, not duplicated'); }); test('JSON bodies are lightly gzip-compressed on disk', () async { final body = List.filled(500, 'compressible').join(','); await store.write('https://x/big', etag: '1', body: body); - final rows = await db.query( - 'http_cache', - columns: ['body'], - where: 'key = ?', - whereArgs: ['https://x/big'], - ); - final blob = rows.first['body'] as Uint8List; + final row = await rowFor('https://x/big', columns: 'body'); + final blob = row['body'] as Uint8List; expect(blob[0], 0x1f, reason: 'gzip magic'); expect(blob.length, lessThan(body.length)); }); @@ -79,14 +88,9 @@ void main() { bytes: bytes, contentType: 'image/webp', ); - final rows = await db.query( - 'http_cache', - columns: ['kind', 'body'], - where: 'key = ?', - whereArgs: ['https://x/t.webp'], - ); - expect(rows.first['kind'], EtagCacheStore.kindBinary); - expect(rows.first['body'], bytes); + final row = await rowFor('https://x/t.webp', columns: 'kind, body'); + expect(row['kind'], EtagCacheStore.kindBinary); + expect(row['body'], bytes); final hit = await store.readBytes('https://x/t.webp'); expect(hit!.bytes, bytes); @@ -112,14 +116,9 @@ void main() { bytes: mvt, contentType: 'application/vnd.mapbox-vector-tile', ); - final mvtRows = await db.query( - 'http_cache', - columns: ['kind', 'body'], - where: 'key = ?', - whereArgs: ['https://x/a.mvt'], - ); - expect(mvtRows.first['kind'], EtagCacheStore.kindBinaryGzip); - expect((mvtRows.first['body'] as Uint8List).length, lessThan(mvt.length)); + final mvtRow = await rowFor('https://x/a.mvt', columns: 'kind, body'); + expect(mvtRow['kind'], EtagCacheStore.kindBinaryGzip); + expect((mvtRow['body'] as Uint8List).length, lessThan(mvt.length)); // Basemap LB serves application/octet-stream. final pbf = Uint8List.fromList([ @@ -133,30 +132,20 @@ void main() { 0x62, ...List.filled(600, 0x41), ]); + const pbfUrl = + 'https://static.lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf'; await store.writeBytes( - 'https://static.lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', + pbfUrl, etag: 'W/"u1"', bytes: pbf, contentType: 'application/octet-stream', ); - final pbfRows = await db.query( - 'http_cache', - columns: ['kind', 'body'], - where: 'key = ?', - whereArgs: [ - 'https://static.lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', - ], - ); - expect(pbfRows.first['kind'], EtagCacheStore.kindBinaryGzip); + final pbfRow = await rowFor(pbfUrl, columns: 'kind'); + expect(pbfRow['kind'], EtagCacheStore.kindBinaryGzip); final cold = EtagCacheStore(db); expect((await cold.readBytes('https://x/a.mvt'))!.bytes, mvt); - expect( - (await cold.readBytes( - 'https://static.lb.exptech.dev/api/v1/map/tiles/7/107/55.pbf', - ))!.bytes, - pbf, - ); + expect((await cold.readBytes(pbfUrl))!.bytes, pbf); }); test('SVG / text binaries are gzip-1 on disk', () async { @@ -169,13 +158,8 @@ void main() { bytes: svg, contentType: 'image/svg+xml', ); - final rows = await db.query( - 'http_cache', - columns: ['kind'], - where: 'key = ?', - whereArgs: ['https://x/i.svg'], - ); - expect(rows.first['kind'], EtagCacheStore.kindBinaryGzip); + final row = await rowFor('https://x/i.svg', columns: 'kind'); + expect(row['kind'], EtagCacheStore.kindBinaryGzip); final cold = EtagCacheStore(db); expect((await cold.readBytes('https://x/i.svg'))!.bytes, svg); }); @@ -299,12 +283,7 @@ void main() { final eightDaysAgo = DateTime.now() .subtract(const Duration(days: 8)) .millisecondsSinceEpoch; - await db.update( - 'http_cache', - {'time': eightDaysAgo}, - where: 'key = ?', - whereArgs: ['https://x/old'], - ); + await setTime('https://x/old', eightDaysAgo); await store.write('https://x/new', etag: '2', body: 'B'); @@ -391,18 +370,8 @@ void main() { // Pin last-used near "now" so the LRU order is explicit, with b older // than a (buffered touch timers can't scramble the order). final now = DateTime.now().millisecondsSinceEpoch; - await db.update( - 'http_cache', - {'time': now - 2_000}, - where: 'key = ?', - whereArgs: ['https://x/a'], - ); - await db.update( - 'http_cache', - {'time': now - 4_000}, - where: 'key = ?', - whereArgs: ['https://x/b'], - ); + await setTime('https://x/a', now - 2_000); + await setTime('https://x/b', now - 4_000); await tight.writeBytes( 'https://x/c', etag: '3', @@ -470,12 +439,7 @@ void main() { final eightDaysAgo = DateTime.now() .subtract(const Duration(days: 8)) .millisecondsSinceEpoch; - await db.update( - 'http_cache', - {'time': eightDaysAgo}, - where: 'key = ?', - whereArgs: ['https://x/old'], - ); + await setTime('https://x/old', eightDaysAgo); await tight.writeBytes( 'https://x/new', @@ -518,14 +482,18 @@ void main() { // The interceptor turns a miss into a retryable reject and the retry // fetches a full 200 — a decode throw inside an interceptor would be a // failed request instead. - await db.insert('http_cache', { - 'key': 'https://example.test/broken', - 'etag': '"x"', - 'kind': EtagCacheStore.kindJson, - 'body': Uint8List.fromList([0x7b, 0x22]), // truncated '{"' - 'size': 2, - 'time': 0, - }); + await db.execute( + 'INSERT INTO http_cache (key, etag, kind, body, size, time) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + [ + 'https://example.test/broken', + '"x"', + EtagCacheStore.kindJson, + Uint8List.fromList([0x7b, 0x22]), // truncated '{"' + 2, + 0, + ], + ); expect(await store.readJson('https://example.test/broken'), isNull); }); } diff --git a/test/core/network/etag_interceptor_test.dart b/test/core/network/etag_interceptor_test.dart index 147684ca8..4cb090313 100644 --- a/test/core/network/etag_interceptor_test.dart +++ b/test/core/network/etag_interceptor_test.dart @@ -5,7 +5,9 @@ import 'package:dpip/core/network/dio_client.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/etag_interceptor.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import '../storage/memory_db.dart'; /// A Dio adapter that answers `304` when the request carries the matching /// `If-None-Match`, else `200` with [body] and (optionally) an ETag — enough to @@ -54,13 +56,11 @@ class _EvictedStore extends EtagCacheStore { } void main() { - late Database db; + late SqliteDatabase db; late EtagCacheStore store; - setUpAll(sqfliteFfiInit); - setUp(() async { - db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + db = openMemoryDb(); await EtagCacheStore.createSchema(db); store = EtagCacheStore(db); }); diff --git a/test/core/network/network_usage_store_test.dart b/test/core/network/network_usage_store_test.dart index 99023887b..03192ec28 100644 --- a/test/core/network/network_usage_store_test.dart +++ b/test/core/network/network_usage_store_test.dart @@ -1,16 +1,16 @@ import 'package:dpip/core/network/network_usage_store.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import '../storage/memory_db.dart'; void main() { - late Database db; + late SqliteDatabase db; var now = DateTime.utc(2026, 1, 10, 12); - setUpAll(sqfliteFfiInit); - setUp(() async { now = DateTime.utc(2026, 1, 10, 12); - db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + db = openMemoryDb(); await NetworkUsageStore.createSchema(db); }); @@ -18,6 +18,9 @@ void main() { NetworkUsageStore store() => NetworkUsageStore(db, now: () => now); + Future>> rows() => + db.getAll('SELECT * FROM net_bucket'); + test('empty store reports zeros and a zero hit rate', () async { final s = await store().stats(); expect(s.last24h, 0); @@ -122,7 +125,7 @@ void main() { 'no longer exists', ); expect( - await db.query('net_bucket'), + await rows(), isEmpty, reason: 'the buffered aggregate must go too, or the counters reappear a ' @@ -141,7 +144,7 @@ void main() { await s.record(down: 10, hit: true, saved: 5); } // Still buffered — the table is empty until flush/stats. - expect(await db.query('net_bucket'), isEmpty); + expect(await rows(), isEmpty); final stats = await s.stats(); expect(stats.hits24h, 50); diff --git a/test/core/settings/settings_store_test.dart b/test/core/settings/settings_store_test.dart index e7767f845..8694705d9 100644 --- a/test/core/settings/settings_store_test.dart +++ b/test/core/settings/settings_store_test.dart @@ -11,29 +11,24 @@ library; import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; -/// A fresh in-memory database per call. -/// -/// `singleInstance: false` matters: sqflite hands back the *same* handle for a -/// repeated path, and `:memory:` is a path — so without it every test in the -/// file shares one database and the second test starts with the first one's -/// rows. That is exactly the kind of shared state that makes a suite pass in -/// isolation and fail as a group. -Future _openMemory() => databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), -); +import '../storage/memory_db.dart'; -Future _db() async { - final db = await _openMemory(); +Future _db() async { + final db = openMemoryDb(); await SettingsStore.createSchema(db); return db; } +Future insertRow(SqliteDatabase db, String key, String value) => + db.execute('INSERT INTO $settingsTable (key, value) VALUES (?, ?)', [ + key, + value, + ]); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - sqfliteFfiInit(); test('a value survives a reopen', () async { final db = await _db(); @@ -72,14 +67,8 @@ void main() { test('one malformed row does not discard onboarding completion', () async { final db = await _db(); - await db.insert(settingsTable, { - 'key': SettingKeys.locale.name, - 'value': '{not json', - }); - await db.insert(settingsTable, { - 'key': SettingKeys.onboardingComplete.name, - 'value': 'true', - }); + await insertRow(db, SettingKeys.locale.name, '{not json'); + await insertRow(db, SettingKeys.onboardingComplete.name, 'true'); final store = await SettingsStore.open(db); diff --git a/test/core/storage/app_database_test.dart b/test/core/storage/app_database_test.dart index 1d564788d..a7f615721 100644 --- a/test/core/storage/app_database_test.dart +++ b/test/core/storage/app_database_test.dart @@ -20,37 +20,27 @@ import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/core/storage/app_database.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; -/// A fresh in-memory database per call. -/// -/// `singleInstance: false` matters: sqflite hands back the *same* handle for a -/// repeated path, and `:memory:` is a path — so without it every test in the -/// file shares one database and the second test starts with the first one's -/// rows. That is exactly the kind of shared state that makes a suite pass in -/// isolation and fail as a group. -Future _openMemory() => databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), -); +import 'memory_db.dart'; -Future _durable() async { - final db = await _openMemory(); +Future _durable() async { + final db = openMemoryDb(); await SettingsStore.createSchema(db); await TleStore.createSchema(db); await MeshStore.createSchema(db); return db; } -Future _cache() async { - final db = await _openMemory(); +Future _cache() async { + final db = openMemoryDb(); await EtagCacheStore.createSchema(db); await NetworkUsageStore.createSchema(db); return db; } -Future> _tables(Database db) async { - final rows = await db.rawQuery( +Future> _tables(SqliteDatabase db) async { + final rows = await db.getAll( "SELECT name FROM sqlite_master WHERE type = 'table' " "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'android_%'", ); @@ -59,7 +49,6 @@ Future> _tables(Database db) async { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - sqfliteFfiInit(); test('clearing the cache leaves every other category intact', () async { final durable = await _durable(); @@ -84,22 +73,17 @@ void main() { {'num': 7, 'name': 'repeater', 'snr': 0.0, 'via_mqtt': 0}, ]); // And something in the cache. - await cache.insert('http_cache', { - 'key': 'https://example.test/a', - 'etag': 'x', - 'kind': EtagCacheStore.kindJson, - 'body': 'hello', - 'size': 5, - 'time': 0, - }); + await cache.execute( + 'INSERT INTO http_cache (key, etag, kind, body, size, time) ' + "VALUES ('https://example.test/a', 'x', ?, 'hello', 5, 0)", + [EtagCacheStore.kindJson], + ); expect(await database.clearCache(), greaterThan(0)); // The cache is empty… - expect( - (await cache.rawQuery('SELECT COUNT(*) AS n FROM http_cache')).first['n'], - 0, - ); + final count = await cache.get('SELECT COUNT(*) AS n FROM http_cache'); + expect(count['n'], 0); // …and every other category survived. final after = await SettingsStore.open(durable); expect(after.getBool(SettingKeys.onboardingComplete), isTrue); @@ -114,14 +98,11 @@ void main() { // Constructed with a cache and *no* durable database at all. If clearing // ever needed the other file, this would throw rather than quietly work. final cache = await _cache(); - await cache.insert('http_cache', { - 'key': 'https://example.test/b', - 'etag': 'y', - 'kind': EtagCacheStore.kindJson, - 'body': 'bytes', - 'size': 5, - 'time': 0, - }); + await cache.execute( + 'INSERT INTO http_cache (key, etag, kind, body, size, time) ' + "VALUES ('https://example.test/b', 'y', ?, 'bytes', 5, 0)", + [EtagCacheStore.kindJson], + ); const database = AppDatabase(durable: null, cache: null); expect(await database.clearCache(), 0, reason: 'no cache, nothing to do'); diff --git a/test/core/storage/memory_db.dart b/test/core/storage/memory_db.dart new file mode 100644 index 000000000..2cac43018 --- /dev/null +++ b/test/core/storage/memory_db.dart @@ -0,0 +1,23 @@ +/// A fresh in-memory database per call, for store tests. +/// +/// sqlite_async runs every connection on its own background isolate, so the +/// usual `:memory:` path would give each connection a *different* database. +/// [SqliteDatabase.singleConnection] wraps one synchronous connection instead: +/// single handle, one database, no pool — exactly what a unit test wants and +/// what the production pool must not be. +library; + +import 'package:sqlite3/sqlite3.dart' as sqlite3; +import 'package:sqlite_async/sqlite_async.dart'; + +/// Opens an isolated in-memory database. +/// +/// `SqliteConnection.synchronousWrapper` + `SqliteDatabase.singleConnection` is +/// the documented test-only route to in-memory databases; the internal import +/// it replaces is not needed at all. +SqliteDatabase openMemoryDb() { + final connection = SqliteConnection.synchronousWrapper( + sqlite3.sqlite3.openInMemory(), + ); + return SqliteDatabase.singleConnection(connection); +} diff --git a/test/core/storage/retention_test.dart b/test/core/storage/retention_test.dart index bc7942565..eae6b08cf 100644 --- a/test/core/storage/retention_test.dart +++ b/test/core/storage/retention_test.dart @@ -14,13 +14,12 @@ import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; import 'package:dpip/core/storage/retention.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; -Future _open() async { - final db = await databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), - ); +import 'memory_db.dart'; + +Future _open() async { + final db = openMemoryDb(); await MeshStore.createSchema(db); await LogStore.createSchema(db); await NetworkUsageStore.createSchema(db); @@ -41,14 +40,13 @@ RetentionService _service({ httpCache: httpCache, ); -Future _count(Database db, String table) async { - final rows = await db.rawQuery('SELECT COUNT(*) AS n FROM $table'); - return rows.first['n']! as int; +Future _count(SqliteDatabase db, String table) async { + final row = await db.get('SELECT COUNT(*) AS n FROM $table'); + return (row['n'] as num).toInt(); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(sqfliteFfiInit); test('a sweep drops what is past its window and keeps the rest', () async { final db = await _open(); @@ -91,16 +89,22 @@ void main() { final db = await _open(); addTearDown(db.close); final now = DateTime.utc(2026, 8, 15, 12); - await db.insert(logTable, { - 'time': now.subtract(const Duration(hours: 30)).millisecondsSinceEpoch, - 'level': 'info', - 'message': 'yesterday', - }); - await db.insert(logTable, { - 'time': now.subtract(const Duration(hours: 1)).millisecondsSinceEpoch, - 'level': 'info', - 'message': 'recent', - }); + await db.execute( + 'INSERT INTO $logTable (time, level, message) VALUES (?, ?, ?)', + [ + now.subtract(const Duration(hours: 30)).millisecondsSinceEpoch, + 'info', + 'yesterday', + ], + ); + await db.execute( + 'INSERT INTO $logTable (time, level, message) VALUES (?, ?, ?)', + [ + now.subtract(const Duration(hours: 1)).millisecondsSinceEpoch, + 'info', + 'recent', + ], + ); final logs = LogStore(db, now: () => now); await _service(logs: logs).sweep(); @@ -162,13 +166,11 @@ void main() { const hourMs = 3600 * 1000; final hour = now.millisecondsSinceEpoch ~/ hourMs; for (final offset in [1, 24 * 7 + 10]) { - await db.insert('net_bucket', { - 'hour': hour - offset, - 'down': 1000, - 'saved': 0, - 'hits': 0, - 'misses': 1, - }); + await db.execute( + 'INSERT INTO net_bucket (hour, down, saved, hits, misses) ' + 'VALUES (?, 1000, 0, 0, 1)', + [hour - offset], + ); } expect(await _count(db, 'net_bucket'), 2); @@ -185,14 +187,17 @@ void main() { final db = await _open(); addTearDown(db.close); for (var i = 0; i < 20; i++) { - await db.insert('http_cache', { - 'key': 'https://example.test/$i', - 'etag': '"$i"', - 'kind': EtagCacheStore.kindJson, - 'body': 'x' * 100, - 'size': 100, - 'time': i, - }); + await db.execute( + "INSERT INTO http_cache (key, etag, kind, body, size, time) " + "VALUES (?, ?, ?, ?, 100, ?)", + [ + 'https://example.test/$i', + '"$i"', + EtagCacheStore.kindJson, + 'x' * 100, + i, + ], + ); } expect(await _count(db, 'http_cache'), 20); @@ -219,18 +224,19 @@ void main() { MeshMetricSample(at: now.subtract(const Duration(days: 3))), ); await mesh.addMetric(MeshMetricSample(at: now)); - await db.insert(logTable, { - 'time': now.subtract(const Duration(days: 3)).millisecondsSinceEpoch, - 'level': 'info', - 'message': 'old', - }); - await db.insert('net_bucket', { - 'hour': now.millisecondsSinceEpoch ~/ (3600 * 1000) - (24 * 7 + 10), - 'down': 1, - 'saved': 0, - 'hits': 0, - 'misses': 1, - }); + await db.execute( + 'INSERT INTO $logTable (time, level, message) VALUES (?, ?, ?)', + [ + now.subtract(const Duration(days: 3)).millisecondsSinceEpoch, + 'info', + 'old', + ], + ); + await db.execute( + 'INSERT INTO net_bucket (hour, down, saved, hits, misses) ' + 'VALUES (?, 1, 0, 0, 1)', + [now.millisecondsSinceEpoch ~/ (3600 * 1000) - (24 * 7 + 10)], + ); await RetentionService( mesh: MeshStore(db, now: () => now), diff --git a/test/features/meshtastic/mesh_channel_names_test.dart b/test/features/meshtastic/mesh_channel_names_test.dart index af5ac23fe..04c713f5e 100644 --- a/test/features/meshtastic/mesh_channel_names_test.dart +++ b/test/features/meshtastic/mesh_channel_names_test.dart @@ -16,9 +16,10 @@ import 'package:dpip/core/meshtastic/mesh_link.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/features/meshtastic/presentation/mesh_chat_controller.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; import '../../core/meshtastic/fake_mesh_service.dart'; +import '../../core/storage/memory_db.dart'; /// A service whose channel table can be set and cleared, the way a radio's is /// by connecting and disconnecting. @@ -32,14 +33,8 @@ class _ChannelService extends FakeMeshService { MeshChannel _channel(int index, String name) => MeshChannel(index: index, name: name, psk: const [1], enabled: true); -Future _open() async { - // `singleInstance: false`: sqflite hands back the same handle for a repeated - // path, and `:memory:` is a path — without it every test here shares one - // database and tearDown closes the handle the next one is about to use. - final db = await databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), - ); +Future _open() async { + final db = openMemoryDb(); await MeshStore.createSchema(db); return db; } @@ -48,7 +43,6 @@ Future settle() => Future.delayed(const Duration(milliseconds: 60)); void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(sqfliteFfiInit); test('a name reported by the radio is remembered', () async { final db = await _open(); diff --git a/test/features/meshtastic/mesh_chat_controller_test.dart b/test/features/meshtastic/mesh_chat_controller_test.dart index 977c934f0..61ef96f48 100644 --- a/test/features/meshtastic/mesh_chat_controller_test.dart +++ b/test/features/meshtastic/mesh_chat_controller_test.dart @@ -5,15 +5,15 @@ import 'package:dpip/core/meshtastic/mesh_link.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/features/meshtastic/presentation/mesh_chat_controller.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; import '../../core/meshtastic/fake_mesh_service.dart'; +import '../../core/storage/memory_db.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(sqfliteFfiInit); - late Database db; + late SqliteDatabase db; MeshMessage message(String text, {int from = 1, int seconds = 0}) => MeshMessage( @@ -34,14 +34,7 @@ void main() { if (reuse != null) { store = reuse; } else { - // `singleInstance: false` matters: sqflite hands back the *same* handle - // for a repeated path, and `:memory:` is a path — so without it every - // test here shares one database and `tearDown` closes the handle the - // next test is about to use ("This database has already been closed"). - db = await databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), - ); + db = openMemoryDb(); await MeshStore.createSchema(db); store = MeshStore(db); } diff --git a/test/features/meshtastic/mesh_unread_test.dart b/test/features/meshtastic/mesh_unread_test.dart index 6f4fc1739..920c87ed7 100644 --- a/test/features/meshtastic/mesh_unread_test.dart +++ b/test/features/meshtastic/mesh_unread_test.dart @@ -18,15 +18,13 @@ import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/features/meshtastic/presentation/mesh_chat_controller.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; import '../../core/meshtastic/fake_mesh_service.dart'; +import '../../core/storage/memory_db.dart'; -Future _open() async { - final db = await databaseFactoryFfi.openDatabase( - inMemoryDatabasePath, - options: OpenDatabaseOptions(singleInstance: false), - ); +Future _open() async { + final db = openMemoryDb(); await MeshStore.createSchema(db); return db; } @@ -42,7 +40,9 @@ MeshMessage _incoming(String text, int channel, {int seconds = 0}) => Future _settle() => Future.delayed(const Duration(milliseconds: 80)); -Future<(MeshChatController, FakeMeshService)> _controller(Database db) async { +Future<(MeshChatController, FakeMeshService)> _controller( + SqliteDatabase db, +) async { final service = FakeMeshService(); final settings = SettingsStore.inMemory(); final controller = MeshChatController( @@ -56,7 +56,6 @@ Future<(MeshChatController, FakeMeshService)> _controller(Database db) async { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(sqfliteFfiInit); group('unread', () { test('accrues off-screen, never on-screen, never for own sends', () async { @@ -205,13 +204,11 @@ void main() { final db = await _open(); addTearDown(db.close); // A row written before the channel-hash guard: `channel` holds a hash. - await db.insert('mesh_messages', { - 'ts': DateTime.utc(2026, 1, 1).millisecondsSinceEpoch, - 'node': 7, - 'channel': 242, - 'text': 'foreign', - 'outgoing': 0, - }); + await db.execute( + 'INSERT INTO mesh_messages (ts, node, channel, text, outgoing) ' + 'VALUES (?, 7, 242, ?, 0)', + [DateTime.utc(2026, 1, 1).millisecondsSinceEpoch, 'foreign'], + ); final store = MeshStore(db); await store.prune(); expect( diff --git a/test/features/meshtastic/meshtastic_page_channel_test.dart b/test/features/meshtastic/meshtastic_page_channel_test.dart index 81afe2db7..8b8b68a87 100644 --- a/test/features/meshtastic/meshtastic_page_channel_test.dart +++ b/test/features/meshtastic/meshtastic_page_channel_test.dart @@ -19,13 +19,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import '../../core/meshtastic/fake_mesh_service.dart'; +import '../../core/storage/memory_db.dart'; void main() { - setUpAll(sqfliteFfiInit); - MeshStoredMessage stored(String text, int channel, int seconds) => MeshStoredMessage( from: 1, @@ -51,7 +49,7 @@ void main() { // and the test simply hangs. await tester.runAsync(() async { final settings = SettingsStore.inMemory({}); - final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + final db = openMemoryDb(); addTearDown(db.close); await MeshStore.createSchema(db); final store = MeshStore(db); diff --git a/test/shared/map/map_tile_cache_test.dart b/test/shared/map/map_tile_cache_test.dart index f2629326d..c660b6591 100644 --- a/test/shared/map/map_tile_cache_test.dart +++ b/test/shared/map/map_tile_cache_test.dart @@ -12,7 +12,9 @@ import 'package:dpip/shared/map/map_tile_warmer.dart'; import 'package:dpip/features/weather/data/frame_tile_repository.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import '../../core/storage/memory_db.dart'; const terrainUrl = 'https://static.lb.exptech.dev/api/v1/map/terrain/7/107/55.png'; @@ -74,7 +76,7 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; const codec = StandardMethodCodec(); - late Database db; + late SqliteDatabase db; late EtagCacheStore store; late MapTileCache cache; late List nativeCalls; @@ -85,10 +87,8 @@ void main() { Completer? blockedInject; Completer? injectStarted; - setUpAll(sqfliteFfiInit); - setUp(() async { - db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + db = openMemoryDb(); await EtagCacheStore.createSchema(db); await NetworkUsageStore.createSchema(db); store = EtagCacheStore(db); diff --git a/tool/check/storage.sh b/tool/check/storage.sh index 9bf8c1b2e..8f743f136 100755 --- a/tool/check/storage.sh +++ b/tool/check/storage.sh @@ -7,17 +7,18 @@ # `SettingKey` and never a raw `String`, so an ad-hoc key cannot reach # storage. Nothing may import the package; it is not a dependency. # -# 2. `sqflite` is opened and schema'd only by the stores that own a table, plus -# bootstrap (which mints the handles). A feature reaching for a Database -# would be a feature able to drop somebody else's table — which is exactly -# what "clearing the cache must not delete anything else" is about. +# 2. SQLite (`sqlite_async`) is opened and schema'd only by the stores that +# own a table, plus bootstrap (which mints the handles). A feature reaching +# for a SqliteDatabase would be a feature able to drop somebody else's +# table — which is exactly what "clearing the cache must not delete +# anything else" is about. # # Sibling to tool/check/layering.sh / check_l10n.sh; zero new packages. # See ARCHITECTURE.md → Persistence. set -euo pipefail cd "$(dirname "$0")/../.." -sqflite_allow='lib/bootstrap.dart +sqlite_allow='lib/bootstrap.dart lib/core/settings/settings_store.dart lib/core/logging/log_store.dart lib/core/storage/app_database.dart @@ -36,15 +37,17 @@ while IFS= read -r file; do echo " use SettingsStore (lib/core/settings/settings_store.dart)" fail=1 fi - if grep -qE "(import|export)[[:space:]]+['\"]package:sqflite" "$file"; then + # The sqlite3 FFI is likewise store-only: raw handles bypass the pool's lock + # discipline that sqlite_async exists to provide. + if grep -qE "(import|export)[[:space:]]+['\"]package:(sqflite|sqlite3)" "$file"; then case " -$sqflite_allow +$sqlite_allow " in *" $file "*) ;; *) - echo " ✗ $file opens sqflite directly — go through the store that owns" + echo " ✗ $file opens SQLite directly — go through the store that owns" echo " the table (see lib/core/storage/app_database.dart)" fail=1 ;; From 94d9a77f73f7b60db66b0c013808febc03f43161 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Sun, 23 Aug 2026 12:20:25 +0800 Subject: [PATCH 02/40] fix(storage): recover a session whose database opens late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 資料庫開啟延遲時,設定會先留在記憶體、開啟後自動補回,不再整段工作階段被當成第一次使用 Fix(en-US): settings survive a session without a database — they stay in memory and are replayed when it opens, instead of the session masquerading as a first run --- lib/app/router/app_router.dart | 14 +++ lib/bootstrap.dart | 58 ++++++++++++- lib/core/settings/onboarding_store.dart | 7 ++ lib/core/settings/settings_store.dart | 96 ++++++++++++++++++++- test/core/settings/settings_store_test.dart | 44 ++++++++++ 5 files changed, 214 insertions(+), 5 deletions(-) diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 10e35ef0b..a514ad904 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -38,9 +38,22 @@ import 'package:dpip/features/status/presentation/pages/server_status_page.dart' import 'package:dpip/features/weather/presentation/pages/weather_ranking_page.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; +import 'package:flutter/foundation.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +/// Fires when a redirect *input* changes outside any navigation — today only +/// the background durable-database recovery adopting settings rows this +/// session launched without seeing ([bootstrap] calls [fire]). Without it, +/// GoRouter re-runs [redirect] on navigation events alone, so a returning +/// user held on the welcome page by a failed launch would stay there all +/// session even after the completion flag came back. +final OnboardingRefreshSignal onboardingRefresh = OnboardingRefreshSignal(); + +final class OnboardingRefreshSignal extends ChangeNotifier { + void fire() => notifyListeners(); +} + /// The application's route table. /// /// A [StatefulShellRoute] hosts the five bottom-navigation branches, in the @@ -49,6 +62,7 @@ import 'package:provider/provider.dart'; /// page widgets to navigate. final GoRouter appRouter = GoRouter( initialLocation: AppRoutes.homePath, + refreshListenable: onboardingRefresh, // Lets the shell notice that one of the full-screen routes below has covered // it, so the tabs underneath can idle instead of animating at nobody. observers: [shellRouteObserver], diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index f46569685..7d82dac7e 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart' show kDebugMode, kReleaseMode; import 'package:dpip/app/app.dart'; +import 'package:dpip/app/router/app_router.dart' show onboardingRefresh; import 'package:dpip/core/di/core_providers.dart'; import 'package:dpip/core/di/shared_deps.dart'; import 'package:dpip/core/geo/device_location_reporter.dart'; @@ -188,6 +189,12 @@ Future bootstrap() async { 'settings ready durable=${durable != null} keys=${settings.keys.length} ' 'onboarding=${settings.getBool(SettingKeys.onboardingComplete)}', ); + final onboarding = OnboardingStore(settings); + // A launch that could not open the database must not spend the whole session + // pretending to be a first run: keep trying, and when the file opens, hand + // it to the store so this session's writes persist — then re-announce + // onboarding so a redirect held on the welcome page re-runs. + if (durable == null) unawaited(_recoverDurable(settings, onboarding)); // Persist the log as early as the database allows: everything after this // point survives a crash or a background kill, which is exactly the window // the in-memory history used to lose. @@ -195,7 +202,6 @@ Future bootstrap() async { if (logStore != null) Log.persistTo(logStore); final regions = RegionSelection(settings); final experimental = ExperimentalSettings(settings); - final onboarding = OnboardingStore(settings); final locale = LocaleController(settings); final theme = ThemeController(settings); // Constructed before the first frame: it installs the saved setting into @@ -445,8 +451,8 @@ _openCache() async { /// background isolate with WAL and a built-in lock timeout (30 s default), so /// contention waits instead of failing. What remains here is the honest /// fallback for an open that still fails (missing parent directory, full disk, -/// first-unlock encryption state): bounded retries at launch, then the session -/// runs without persistence. Every schema statement is `IF NOT EXISTS` +/// first-unlock encryption state): bounded retries, then [_recoverDurable] +/// keeps trying off the launch path. Every schema statement is `IF NOT EXISTS` /// and runs on every open, so a database created by an older build picks up /// tables added later without a version bump. Future _openDurable() async { @@ -486,6 +492,52 @@ Future _openDurable() async { return null; } +/// Keeps trying to open the durable database after a launch that could not — +/// a degraded session is recoverable, not terminal. +/// +/// A slow first unlock or a genuinely wedged file can still cost the open at +/// launch. Rather than showing a returning user onboarding for the rest of the +/// session, poll until the file opens (or the budget runs out), then hand it +/// to [SettingsStore.attachDatabase]: memory stays authoritative, disk catches +/// up, and everything written in between survives. The onboarding store then +/// re-announces, so a router redirect held on the welcome page re-runs and +/// releases the user the moment the flag is back. +Future _recoverDurable( + SettingsStore settings, + OnboardingStore onboarding, +) async { + const attempts = 10; + const interval = Duration(seconds: 3); + for (var attempt = 1; attempt <= attempts; attempt++) { + await Future.delayed(interval); + try { + final base = await getApplicationSupportDirectory(); + final db = SqliteDatabase( + path: '${base.path}/dpip.db', + options: const SqliteOptions(synchronous: SqliteSynchronous.full), + ); + await _createDurableSchema(db); + final moved = await settings.attachDatabase(db); + Log.info( + 'durable database attached in the background' + '${moved ? '; reconciled session-only writes' : ''}', + ); + // Rows adopted from disk (onboarding.complete among them) only matter + // once listeners re-read them: OnboardingStore's listeners re-read the + // store, and the router's redirect re-runs on the refresh signal. + onboarding.reload(); + onboardingRefresh.fire(); + return; + } catch (error) { + Log.warning('durable recovery attempt $attempt/$attempts failed: $error'); + } + } + Log.error( + 'durable database never opened this session; ' + 'settings will not persist until the next launch', + ); +} + Future _createDurableSchema(SqliteDatabase db) async { await SettingsStore.createSchema(db); await LogStore.createSchema(db); diff --git a/lib/core/settings/onboarding_store.dart b/lib/core/settings/onboarding_store.dart index 83ef220aa..00de86e3e 100644 --- a/lib/core/settings/onboarding_store.dart +++ b/lib/core/settings/onboarding_store.dart @@ -24,4 +24,11 @@ class OnboardingStore extends ChangeNotifier { await _settings.setBool(SettingKeys.onboardingComplete, true); notifyListeners(); } + + /// Re-announces state after an external writer changed it underneath this + /// store — the background durable-database recovery adopting rows this + /// session never saw. Listeners (the services host, and anything gating on + /// [isComplete]) re-read; the router's redirect re-runs on the refresh that + /// follows, releasing a returning user held on the welcome page. + void reload() => notifyListeners(); } diff --git a/lib/core/settings/settings_store.dart b/lib/core/settings/settings_store.dart index 6e04d9ce8..99aaf56b3 100644 --- a/lib/core/settings/settings_store.dart +++ b/lib/core/settings/settings_store.dart @@ -15,6 +15,13 @@ /// memory immediately and reach the database in the background. The trade is /// deliberate and stated here rather than hidden inside a plugin. /// +/// A launch where the database would not open degrades to a **session-only** +/// store: reads answer what memory holds (nothing) and writes stay in memory. +/// Every such write is recorded in [_pendingWrites] and warned once, so a +/// degraded session is visible in the log and reversible — [attachDatabase] +/// replays those writes once a database is opened later in the same session +/// (see `_recoverDurable` in `bootstrap.dart`). +/// /// A write that fails is logged, not thrown: a setting that did not persist is /// worth a log line, never a crash in a settings screen. library; @@ -41,6 +48,11 @@ final class SettingsStore { /// The whole table, in memory. final Map _values; + /// Writes made while [_db] was null — the degraded-session backlog that + /// [attachDatabase] replays. A removal is remembered as a removal (null + /// value), so replaying cannot resurrect a deleted key. + final Map _pendingWrites = {}; + /// Creates the table. Safe to call on every open. static Future createSchema(SqliteDatabase db) => db.execute( 'CREATE TABLE IF NOT EXISTS $settingsTable (' @@ -125,7 +137,10 @@ final class SettingsStore { Future remove(SettingKey key) async { _values.remove(key.name); final db = _db; - if (db == null) return; + if (db == null) { + _pendingWrites[key.name] = null; + return; + } try { await db.execute('DELETE FROM $settingsTable WHERE key = ?', [key.name]); } catch (error, stackTrace) { @@ -137,10 +152,87 @@ final class SettingsStore { /// finished, and by the debug page. Iterable get keys => _values.keys; + /// Whether this session is running without a database — reads still work, + /// but nothing written here survives the process. + bool get isDegraded => _db == null; + + /// Binds a database to a store that launched without one, and reconciles + /// the two directions: + /// + /// 1. **Session → disk**: writes made while degraded are replayed, removals + /// included, so what the user did this session survives. + /// 2. **Disk → session**: rows the session never saw (the whole table, for + /// a launch whose open failed) are adopted into memory — *only* keys + /// memory has no opinion on, never overwriting what the session read or + /// wrote. This is what un-degrades the launch: `onboarding.complete`, + /// saved regions and the push token come back instead of the session + /// spending its whole life looking like a first run. + /// + /// Returns whether anything moved in either direction. Attaching to an + /// already-attached store is a no-op that answers false. + Future attachDatabase(SqliteDatabase db) async { + if (_db != null) return false; + var moved = false; + // Replay first: a session write over the same key must win over the + // stale row the database still holds from before the degradation. + final pending = Map.of(_pendingWrites); + _pendingWrites.clear(); + try { + for (final MapEntry(key: name, :value) in pending.entries) { + if (value == null) { + await db.execute('DELETE FROM $settingsTable WHERE key = ?', [name]); + } else { + await db.execute( + 'INSERT OR REPLACE INTO $settingsTable (key, value) VALUES (?, ?)', + [name, jsonEncode(value)], + ); + } + moved = true; + } + for (final row in await db.getAll( + 'SELECT key, value FROM $settingsTable', + )) { + final name = row['key'] as String?; + final raw = row['value'] as String?; + if (name == null || raw == null || _values.containsKey(name)) continue; + try { + _values[name] = jsonDecode(raw); + moved = true; + } catch (_) { + // A row unreadable at attach time is no better than one unreadable + // at load time — skip it rather than poison the session. + } + } + } catch (error, stackTrace) { + // Whatever failed stays pending for another attempt; the memory copy is + // already authoritative either way. + for (final entry in pending.entries) { + if (!_pendingWrites.containsKey(entry.key)) { + _pendingWrites[entry.key] = entry.value; + } + } + Log.handle(error, stackTrace, 'attaching durable settings database'); + } + _db = db; + return moved; + } + Future _put(SettingKey key, Object value) async { _values[key.name] = value; final db = _db; - if (db == null) return; + if (db == null) { + // A degraded session must not look healthy: without this line a launch + // whose database never opened runs, accepts every setting, and drops + // all of them silently — the "configured install looks like first run" + // bug wearing a different hat. + if (_pendingWrites.isEmpty) { + Log.warning( + 'settings are session-only: the durable database is not open', + ); + } + _pendingWrites[key.name] = value; + return; + } try { await db.execute( 'INSERT OR REPLACE INTO $settingsTable (key, value) VALUES (?, ?)', diff --git a/test/core/settings/settings_store_test.dart b/test/core/settings/settings_store_test.dart index 8694705d9..523409ea2 100644 --- a/test/core/settings/settings_store_test.dart +++ b/test/core/settings/settings_store_test.dart @@ -96,6 +96,50 @@ void main() { expect(store.getString(SettingKeys.locale), 'th'); }); + test('a degraded session is visible, and attach replays both ways', () async { + // The launch whose database never opened: writes stay in memory (and are + // flagged), then the database opens mid-session. The session's own write + // must reach disk, and rows it never saw must come back — a returning + // user's onboarding flag among them — without clobbering what the + // session read or wrote. + final db = await _db(); + await insertRow(db, SettingKeys.onboardingComplete.name, 'true'); + + final store = await SettingsStore.open(null); + expect(store.isDegraded, isTrue); + expect(store.getBool(SettingKeys.onboardingComplete), isNull); + await store.setInt(SettingKeys.channelVersion, 7); + await store.remove(SettingKeys.experimentalUnlocked); + + expect(await store.attachDatabase(db), isTrue); + expect(store.isDegraded, isFalse); + // Disk → session: the unseen row is adopted… + expect(store.getBool(SettingKeys.onboardingComplete), isTrue); + // …and session → disk: the backlog landed, removals included. + expect( + (await SettingsStore.open(db)).getInt(SettingKeys.channelVersion), + 7, + ); + + // A second attach is a no-op. + expect(await store.attachDatabase(db), isFalse); + }); + + test('attach never overwrites what the session holds', () async { + // The database remembers locale=ja from before a degraded launch; during + // that launch the user picked th. The session is what the user sees, so + // th must win in memory *and* on disk after the attach. + final db = await _db(); + await insertRow(db, SettingKeys.locale.name, '"ja"'); + + final store = await SettingsStore.open(null); + await store.setString(SettingKeys.locale, 'th'); + await store.attachDatabase(db); + + expect(store.getString(SettingKeys.locale), 'th'); + expect((await SettingsStore.open(db)).getString(SettingKeys.locale), 'th'); + }); + test('a value written under one key is invisible under another', () { // The registry is the whole persisted surface; two keys sharing a storage // address would make one setting silently overwrite another. From 4a734a0479986b4c426b390adb2534f41aa38618 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Sun, 23 Aug 2026 20:16:31 +0800 Subject: [PATCH 03/40] fix(home): drop a rain card tick that lands after the card leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正首頁雨滴卡片捲出畫面後,會在 App 日誌灌入大量錯誤 Fix(en-US): Fix the home rain card flooding the app log with errors once it scrolls away --- .../presentation/widgets/weather_sky/rain_on_card.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart index 6631ecb21..a8eb9f71b 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -425,6 +425,14 @@ class _RainOnCardState extends State // skipped the whole simulation and left the edge dry. if (dt <= 0 || _size.width <= 0) return; + // A ticker goes on firing between `deactivate()` and `dispose()`, and that + // window is exactly where this card sits when the list scrolls it away or a + // tab teardown removes the page. [_syncPositionGate] reads + // `context.findRenderObject()`, which throws on an inactive element, and + // then calls `setState`, which throws on an unmounted one. Neither is worth + // a frame of physics nobody can see. + if (!mounted) return; + _syncPositionGate(); // Gate closed means the card is leaving the top of the sheet — cut the From 7779993118fc0aa63b9e6314aa08d169bf5b8022 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Sun, 23 Aug 2026 20:16:43 +0800 Subject: [PATCH 04/40] fix(map): outline the precipitation forecast on its own grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正「未來 1 小時降水預報」的掃描範圍沿用雷達圓弧,網格四角被誤標為未觀測 Fix(en-US): Fix the 1h precipitation forecast outlining radar coverage instead of its own grid --- .../presentation/layers/qpesums_layer.dart | 18 +++-- .../layers/qpesums_scan_range.dart | 67 +++++++++++++++++++ .../presentation/layers/radar_scan_range.dart | 10 +-- .../layers/scan_range_overlay_chrome.dart | 21 ++++-- test/features/map/qpesums_layer_test.dart | 25 ++++++- test/features/map/radar_layer_test.dart | 7 ++ .../features/map/raster_timeline_harness.dart | 6 ++ 7 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 lib/features/map/presentation/layers/qpesums_scan_range.dart diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart index 05fd7dd55..631dbc34b 100644 --- a/lib/features/map/presentation/layers/qpesums_layer.dart +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -1,5 +1,6 @@ import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; +import 'package:dpip/features/map/presentation/layers/qpesums_scan_range.dart'; import 'package:dpip/features/map/presentation/layers/scan_range_overlay_chrome.dart'; import 'package:dpip/features/map/presentation/widgets/scan_range_overlay_menu.dart'; import 'package:dpip/features/weather/domain/qpesums_repository.dart'; @@ -17,10 +18,11 @@ import 'package:flutter/material.dart'; /// only the layer's identity, its opacity, and the QPESUMS hourly-rate colour /// key. Frame ids are Unix milliseconds, which [parseFrameTime] already reads. /// -/// The forecast covers the same grid the radar composite observes, so it shares -/// the radar's scan-range geometry — and, like radar, it redraws its own -/// scan-range outline plus county/town borders **over** the raster -/// ([ScanRangeOverlayChrome]), switchable from its options chip. +/// Like radar, it redraws its own coverage outline plus county/town borders +/// **over** the raster ([ScanRangeOverlayChrome]), switchable from its options +/// chip — but not with radar's geometry: the forecast is published over a plain +/// rectangle ([QpesumsScanRange]), while the composite the radars observe is a +/// union of range circles. class QpesumsMapLayer extends RasterTimelineLayer with AdminOutlineChrome, ScanRangeOverlayChrome { QpesumsMapLayer(QpesumsRepository super.repository); @@ -28,10 +30,14 @@ class QpesumsMapLayer extends RasterTimelineLayer /// Distinct from radar's ids: both layers can be on the map at once, and each /// draws its own outline instead of clashing over one source/layer pair. @override - String get scanRangeSourceId => 'qpesums-scan-range'; + String get scanRangeSourceId => QpesumsScanRange.sourceId; @override - String get scanRangeLayerId => 'qpesums-scan-range-outline'; + String get scanRangeLayerId => QpesumsScanRange.outlineLayerId; + + /// The forecast grid's own rectangle, not the radar composite's circles. + @override + Map get scanRangeGeoJson => QpesumsScanRange.geoJson(); @override String get scanRangeColor => '#78909C'.vision; diff --git a/lib/features/map/presentation/layers/qpesums_scan_range.dart b/lib/features/map/presentation/layers/qpesums_scan_range.dart new file mode 100644 index 000000000..83f6911f0 --- /dev/null +++ b/lib/features/map/presentation/layers/qpesums_scan_range.dart @@ -0,0 +1,67 @@ +import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; + +/// The area the QPESUMS next-1-hour precipitation forecast covers. +/// +/// A plain rectangle, and **not** [RadarScanRange]'s geometry. The composite +/// the radars observe is a union of four range circles clipped to a wider grid; +/// the forecast is computed on its own grid and published over all of it, so +/// outlining it with the radar circles claimed coverage on the corners the +/// forecast does have and denied it along the edges of the circles. +/// +/// 442 × 562 cell centres at the same 0.0125° step the composite uses: +/// `118.0 + 441 × 0.0125 = 123.5125` and `20.0 + 561 × 0.0125 = 27.0125`. The +/// bounds are written out rather than derived so the numbers in the file are +/// the numbers on the wire. +abstract final class QpesumsScanRange { + QpesumsScanRange._(); + + /// Grid step, in degrees — the same resolution as the radar composite. + static const double gridResolution = 0.0125; + + static const double west = 118.0; + static const double east = 123.5125; + static const double south = 20.0; + static const double north = 27.0125; + + /// Source and layer ids, kept distinct from the radar raster's own so a map + /// showing both draws two outlines instead of clashing over one. + static const String sourceId = 'qpesums-scan-range'; + static const String outlineLayerId = 'qpesums-scan-range-outline'; + + /// The rectangle as a closed, counter-clockwise `[lon, lat]` ring — right + /// edge up, top edge across, left edge down, and back. + /// + /// Four corners is exact here: this is Web Mercator, where a constant + /// latitude projects to a horizontal straight line and a constant longitude + /// to a vertical one, so densifying the edges would add vertices that all + /// land on the segment already being drawn. + static const List> ring = [ + [east, south], + [east, north], + [west, north], + [west, south], + [east, south], + ]; + + /// The coverage outline as a GeoJSON polygon. + /// + /// A **map**, never an encoded string: `addSource` hands this straight to + /// `NSJSONSerialization.dataWithJSONObject` on iOS, which throws — crashing + /// the app, not returning an error — on a top-level string. + static Map geoJson() => { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'properties': { + 'name': 'effective_extent', + 'note': 'QPESUMS forecast grid, 442×562 at 0.0125°', + }, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, + }, + ], + }; +} diff --git a/lib/features/map/presentation/layers/radar_scan_range.dart b/lib/features/map/presentation/layers/radar_scan_range.dart index 62639145f..3fd8fb950 100644 --- a/lib/features/map/presentation/layers/radar_scan_range.dart +++ b/lib/features/map/presentation/layers/radar_scan_range.dart @@ -185,19 +185,21 @@ abstract final class RadarScanRange { /// Outline only — no fill. The covered area is where the echo itself is, and /// a wash over it would tint every dBZ colour on the map. /// - /// [sourceId]/[layerId] let a second raster (QPESUMS, whose coverage is the - /// same composite) draw its own outline under distinct ids — the defaults are - /// the radar ids, so existing callers pass nothing. + /// [sourceId]/[layerId] let a second raster draw its own outline under + /// distinct ids, and [data] lets it outline its own shape — the QPESUMS + /// forecast publishes a plain rectangle, not this union of range circles. All + /// three default to the radar's, so existing callers pass nothing. static Future add( MapLibreMapController controller, { required String outlineColor, String? belowLayerId, String sourceId = RadarScanRange.sourceId, String layerId = RadarScanRange.outlineLayerId, + Map? data, }) async { await controller.addSource( sourceId, - GeojsonSourceProperties(data: geoJson()), + GeojsonSourceProperties(data: data ?? geoJson()), ); await controller.addLineLayer( sourceId, diff --git a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart index 821782561..3a8023fbe 100644 --- a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart +++ b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart @@ -25,12 +25,14 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// rain*, and an unidentified county is one you cannot act on. /// /// The county / township half of that chrome is [AdminOutlineChrome], which -/// the wind forecast layer shares; this adds the radar scan-range outline on -/// top of it. The geometry is the radar composite's ([RadarScanRange]) for -/// every consumer: QPESUMS forecasts the same grid the radars observe, so its -/// coverage is the same union of range circles. Only the ids differ, so a map -/// showing radar and QPESUMS at once draws two outlines instead of clashing -/// over one. +/// the wind forecast layer shares; this adds the coverage outline on top of it. +/// The geometry defaults to the radar composite's ([RadarScanRange]) and is +/// overridable, because coverage is a fact about the source and not about this +/// chrome: the QPESUMS forecast is published over a plain rectangle, so drawing +/// it with the composite's range circles both claimed coverage it does not have +/// on the corners and denied coverage it does have along the arcs. The ids are +/// per-layer too, so a map showing radar and QPESUMS at once draws two outlines +/// instead of clashing over one. mixin ScanRangeOverlayChrome on AdminOutlineChrome { /// Whether the observed area is outlined. On by default — see the class doc. final ValueNotifier showScanRange = ValueNotifier(true); @@ -48,6 +50,12 @@ mixin ScanRangeOverlayChrome on AdminOutlineChrome { /// outline is never mistaken for precipitation. String get scanRangeColor; + /// The shape this layer outlines as its observed area. + /// + /// Defaults to the radar composite's union of range circles; a source whose + /// data covers something else overrides it. + Map get scanRangeGeoJson => RadarScanRange.geoJson(); + /// All chrome listenables, for a legend that follows the toggles. Listenable get chromeListenable => Listenable.merge([showScanRange, adminChromeListenable]); @@ -106,6 +114,7 @@ mixin ScanRangeOverlayChrome on AdminOutlineChrome { belowLayerId: chromeBelowLayerId, sourceId: scanRangeSourceId, layerId: scanRangeLayerId, + data: scanRangeGeoJson, ); } else { await RadarScanRange.remove( diff --git a/test/features/map/qpesums_layer_test.dart b/test/features/map/qpesums_layer_test.dart index 15039170d..dae29f2d2 100644 --- a/test/features/map/qpesums_layer_test.dart +++ b/test/features/map/qpesums_layer_test.dart @@ -135,8 +135,7 @@ void main() { expect(layer.showScanRange.value, isTrue); expect(layer.showCountyOutline.value, isTrue); expect(layer.showTownOutline.value, isTrue); - // QPESUMS shares the radar composite's geometry but draws its own - // outline under its own ids, so both layers can be on the map at once. + // Its own ids, so radar and QPESUMS can both be on the map at once. expect(controller.calls, contains('addSource:qpesums-scan-range')); expect( controller.calls, @@ -150,6 +149,28 @@ void main() { expect(controller.calls, contains('addLineLayer:admin-town-outline')); }); + test('the outline is the forecast rectangle, not radar coverage', () async { + final (_, controller) = await attached(); + + final geo = controller.sourceData['qpesums-scan-range']; + expect(geo, isNotNull, reason: 'the outline source must carry geometry'); + final feature = (geo!['features'] as List).single as Map; + final geometry = feature['geometry'] as Map; + expect(geometry['type'], 'Polygon'); + expect( + (geometry['coordinates'] as List).single, + // 118.0–123.5125°E, 20.0–27.0125°N: the grid the forecast is published + // on, which is not the union of range circles the radars observe. + [ + [123.5125, 20.0], + [123.5125, 27.0125], + [118.0, 27.0125], + [118.0, 20.0], + [123.5125, 20.0], + ], + ); + }); + test( 'turning the coverage outline off removes the qpesums ids only', () async { diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 3b7a8691e..26c776185 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -4,6 +4,7 @@ import 'package:dpip/shared/map/admin_outline.dart'; import 'package:dpip/shared/map/map_style.dart' show outlineLayerId, townLabelLayerId; import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; +import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/shared/map/raster_frame_source.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -1100,6 +1101,12 @@ void main() { controller.calls, contains('addLineLayer:radar-scan-range-outline'), ); + // Radar keeps the composite's geometry. Only the QPESUMS forecast, whose + // grid is a plain rectangle, overrides it — see qpesums_layer_test.dart. + expect( + controller.sourceData['radar-scan-range'], + RadarScanRange.geoJson(), + ); expect( controller.calls, contains('addLineLayer:admin-county-outline-casing'), diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index b167264d3..0633ac767 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -96,6 +96,10 @@ class RecordingMapController implements MapLibreMapController { /// Number of native property batches, regardless of updates per batch. int propertyBatches = 0; + /// GeoJSON handed to `addSource`, by source id — what the map was actually + /// told to draw, rather than merely that it was told something. + final Map> sourceData = {}; + /// How many times the visible region was asked for. It is a platform /// round-trip, so a scrub that re-derives a rectangle the camera never moved /// is paying for it once per crossed frame. @@ -121,6 +125,7 @@ class RecordingMapController implements MapLibreMapController { 'a top-level string crashes NSJSONSerialization on iOS', ); } + if (data is Map) sourceData[sourceId] = Map.from(data); calls.add('addSource:$sourceId'); } @@ -130,6 +135,7 @@ class RecordingMapController implements MapLibreMapController { Map geojson, { String? promoteId, }) async { + sourceData[sourceId] = geojson; calls.add('addSource:$sourceId'); } From b53394bae1da3aa5da0dea43778b2c3bf8d4afdc Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Sun, 23 Aug 2026 22:15:45 +0800 Subject: [PATCH 05/40] docs(api): record what the radar and forecast grids actually cover --- api.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api.md b/api.md index c87742b02..bf59db813 100644 --- a/api.md +++ b/api.md @@ -78,6 +78,11 @@ Basemap、OSM 詳細街道建築與 terrain 都由 MapLibre 直接抓(app 的 tile 是 WebP,放在 **static** 主機(由 MapLibre 直接抓取,`Cache-Control: max-age=300`)。`{sec}` 就是解出清單後的 10 位數秒,直接使用。 +**有效觀測範圍不是宣告網格。** 宣告的是 115–126.5°E、18–29°N(921 × 881 格, +0.0125°),但實際只有四座雷達測距圓的聯集裁到該網格內才有觀測;其餘是空的。 +空白代表「未觀測」而非「無降水」,所以地圖的「顯示掃描範圍」外框畫的是那個聯集, +幾何與推導在 `radar_scan_range.dart`。 + | 方法 | 路徑 | 層級 | 主機 | |---|---|---|---| | `getFrames` | `/api/v2/tiles/radar/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | @@ -106,6 +111,13 @@ QPESUMS 定量降水預報 XYZ WebP。時間清單是差量編碼的 Unix **毫 (`[baseMs, Δ, …]`);tile 在 **static** 主機。`{ms}` 就是解出清單後的 13 位數 毫秒,直接使用(時間軸解析已同時支援秒與毫秒)。 +**覆蓋範圍是方形,整塊都有資料**:118.0–123.5125°E、20.0–27.0125°N,442 × 562 +格心,步長 0.0125°(與雷達同解析度)。 + +這**不是**雷達的有效範圍。預報發布在自己的網格上,雷達的是測距圓聯集,兩者在 +方形四角(預報有、圓弧無)與圓弧外凸處(圓弧有、方形無)都不一致。「顯示掃描 +範圍」外框因此各畫各的幾何,QPESUMS 的在 `qpesums_scan_range.dart`。 + | 方法 | 路徑 | 層級 | 主機 | |---|---|---|---| | `getFrames` | `/api/v2/tiles/qpesums/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | From 03d7c60c063923d0e944067fdfa66e5b382195bd Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 02:29:19 +0800 Subject: [PATCH 06/40] docs(api): match the endpoint table to the code that calls it --- api.md | 179 ++++++++++++++++++++++++++++++++------------------------- 1 file changed, 101 insertions(+), 78 deletions(-) diff --git a/api.md b/api.md index bf59db813..e9bb5e010 100644 --- a/api.md +++ b/api.md @@ -10,11 +10,16 @@ `coreExclusiveApi` = 僅 `api.core-tnn1`、`coreStaticExclusive` = 僅 `static.core-tnn1`、`legacyApi` = 舊 server `api-1`(逐步淘汰中)。 -> 這是**端點目錄**,不是程式碼對照表。沒有 `lib/api/` 巨石檔:每個端點在其所屬 -> feature 的 `data/`(基礎設施則在 `core/`)裡,各自建成一個輕薄的 datasource, -> 並帶著自己的 `ApiTier`(`core/network/api_region.dart`);路徑字串集中於 +> 沒有 `lib/api/` 巨石檔:每個端點在其所屬 feature 的 `data/`(基礎設施則在 +> `core/`)裡,各自建成一個輕薄的 datasource,並帶著自己的 `ApiTier` +> (`core/network/api_region.dart`);路徑字串集中於 > `core/network/api_paths.dart`(與 `EtagInterceptor` 共用,不會漂移)。 > +> **第一欄一律寫成 `類別.方法`。** 只寫方法名的版本曾經整段對不上程式碼 —— +> `getWeatherStations`、`getRainLatest`、`getTyphoonTrack` 這些名字從來不存在, +> 真正的呼叫是一個參數化的 `MeteorSnapshotApi` 加上兩個專用類別。帶著類別名, +> 一次 grep 就能證實或推翻這張表的任何一列。 +> > **對時不是 HTTP 端點。** App 的時鐘使用真正的 **SNTP** > (`flutter_ntp`,UDP/123),對 `time.exptech.com.tw`(主)/ > `time.apple.com`(備),而非 `/ntp` HTTP 呼叫 —— 見 @@ -23,15 +28,15 @@ ## 多活備援 (multi-active) -| 方法 | 路徑 | 層級 | 主機(容錯順序 = 選定區域優先) | +| 類別.方法 | 路徑 | 層級 | 主機(容錯順序 = 選定區域優先) | |---|---|---|---| -| `openEewSse` | `/api/v2/eq/eew?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | -| `openRtsSse` | `/api/v2/trem/rts?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | -| `getRtsRealtime` | `/api/v2/trem/rts` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | -| `getEewRealtime` | `/api/v2/eq/eew` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | -| `getEewAt` | `/api/v2/eq/eew/{sec}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | -| `getReportList` | `/api/v2/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | -| `getReport` | `/api/v2/eq/report/{id}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | +| `EarthquakeApi.openEewSse` | `/api/v2/eq/eew?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | +| `EarthquakeApi.openRtsSse` | `/api/v2/trem/rts?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | +| `EarthquakeApi.getRtsRealtime` | `/api/v2/trem/rts` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | +| `EarthquakeApi.getEewRealtime` | `/api/v2/eq/eew` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | +| `EarthquakeApi.getEewAt` | `/api/v2/eq/eew/{sec}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | +| `EarthquakeApi.getReportList` | `/api/v2/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | +| `EarthquakeApi.getReport` | `/api/v2/eq/report/{id}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | > **地震報告 list(v2)query:** `limit`/`page`、`sort`/`order` > (`time`\|`intensity`\|`magnitude`\|`depth` × `asc`\|`desc`)、震度/規模/深度 @@ -83,10 +88,10 @@ max-age=300`)。`{sec}` 就是解出清單後的 10 位數秒,直接使用 空白代表「未觀測」而非「無降水」,所以地圖的「顯示掃描範圍」外框畫的是那個聯集, 幾何與推導在 `radar_scan_range.dart`。 -| 方法 | 路徑 | 層級 | 主機 | +| 類別.方法 | 路徑 | 層級 | 主機 | |---|---|---|---| -| `getFrames` | `/api/v2/tiles/radar/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | -| `tileUrl` | `/api/v2/tiles/radar/{sec}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `FrameTileApi.getFrames` | `/api/v2/tiles/radar/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | +| `FrameTileApi.tileUrl` | `/api/v2/tiles/radar/{sec}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | ### 衛星雲圖(v2)—— `core-tnn1` @@ -94,16 +99,22 @@ Himawari-9 AHI 的 XYZ WebP,預設是 Band-13 IR。時間清單是差量編碼 Unix 秒(`[baseSec, Δ, …]`),在 API 主機上帶 ETag/304;tile 在 **static** 主機。`{sec}` 就是解出清單後的 10 分鐘秒,直接使用。 -`?channel=` 選取渲染的頻道或產品 —— 單一頻道用數字(`13`)、命名產品用名稱 -(`btd_wvirw`、`cloudtop`…,即 `satellite-tiles-go/docs.md` 的產品目錄)。 -帶 channel 時時間清單為該 channel 的交集(產品需要的頻道缺一就不可渲染, -`list` 只列齊全的時刻)。App 的圖層選擇器為每個 channel 註冊一個獨立圖層 -(`satellite` 保留給 B13,其餘為 `satellite-`)。 +**`{channel}` 與 `{style}` 是路徑段,不是 query。** `{channel}` 選頻道或產品 +—— 單一頻道用數字(`13`,也是省略時的預設)、命名產品用名稱(`btd_wvirw`、 +`cloudtop`…,即 `satellite-tiles-go/docs.md` 的產品目錄)。清單為該 channel 的 +交集(產品需要的頻道缺一就不可渲染,`list` 只列齊全的時刻)。 + +`{style}` 只出現在 tile 路徑:數字頻道可選 `normal` / `jma` / `bd`,命名產品一律 +`normal`(調色盤是產品本身的一部分)。`gray` 會摺成 `normal`。推導在 +`FrameTileApi._satelliteStyle`。 -| 方法 | 路徑 | 層級 | 主機 | +App 的圖層選擇器為每個 channel 註冊一個獨立圖層(`satellite` 保留給 B13,其餘為 +`satellite-`)。 + +| 類別.方法 | 路徑 | 層級 | 主機 | |---|---|---|---| -| `getFrames` | `/api/v2/tiles/satellite/list[?channel=…]` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | -| `tileUrl` | `/api/v2/tiles/satellite/{sec}/{z}/{x}/{y}.webp[?channel=…]` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `FrameTileApi.getFrames` | `/api/v2/tiles/satellite/{channel}/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | +| `FrameTileApi.tileUrl` | `/api/v2/tiles/satellite/{channel}/{style}/{sec}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | ### 未來1小時降水預報 QPESUMS(v2)—— `core-tnn1` @@ -118,10 +129,10 @@ QPESUMS 定量降水預報 XYZ WebP。時間清單是差量編碼的 Unix **毫 方形四角(預報有、圓弧無)與圓弧外凸處(圓弧有、方形無)都不一致。「顯示掃描 範圍」外框因此各畫各的幾何,QPESUMS 的在 `qpesums_scan_range.dart`。 -| 方法 | 路徑 | 層級 | 主機 | +| 類別.方法 | 路徑 | 層級 | 主機 | |---|---|---|---| -| `getFrames` | `/api/v2/tiles/qpesums/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | -| `tileUrl` | `/api/v2/tiles/qpesums/{ms}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `FrameTileApi.getFrames` | `/api/v2/tiles/qpesums/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | +| `FrameTileApi.tileUrl` | `/api/v2/tiles/qpesums/{ms}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | ### 防災地圖 DPM(v2)—— `core-tnn1` @@ -132,24 +143,29 @@ tile 由 MapLibre 直接抓,詳情經 `ApiClient`。Source-layer 名 = `{layer (AED 為 `aed`)。單點有 `id`(內部 PK,打詳情用,非 `aed_id`);低 zoom 的 cluster 帶 `point_count`。 -| 方法 | 路徑 | 層級 | 主機 | +| 類別.方法 | 路徑 | 層級 | 主機 | |---|---|---|---| -| `tileUrl` | `/api/v2/tiles/dpm/{layer}/{z}/{x}/{y}.mvt` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | -| `getAedDetail` | `/api/v2/tiles/dpm/aed/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | -| `getRestroomDetail` | `/api/v2/tiles/dpm/restroom/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | -| `getShelterDetail` | `/api/v2/tiles/dpm/shelter/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `DisasterMapApi.tileUrl` | `/api/v2/tiles/dpm/{layer}/{z}/{x}/{y}.mvt` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `DisasterMapApi.getAedDetail` | `/api/v2/tiles/dpm/aed/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `DisasterMapApi.getRestroomDetail` | `/api/v2/tiles/dpm/restroom/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `DisasterMapApi.getShelterDetail` | `/api/v2/tiles/dpm/shelter/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | ### 風場 Wind(v2 / v1)—— `core-tnn1` 風場 overlay:XYZ WebP 圖層 + 低 zoom 的 **`.bin` 向量風場**(`WND1` 格式, -`fetchWindBin`)。時間清單/圖層與其他 tiles 家族同形狀;`.bin` 用 `{model}` -(`gfs` / `ecmwf`)與 `{frame}` 定址。圖層選擇器把 wind 註冊為獨立圖層。 +`fetchWindBin`)。圖層選擇器把 wind 註冊為獨立圖層。 + +**`{model}` 是路徑段,不是 query,而且一個 frame 定址需要兩個時間。** 預報是 +「哪一次模式跑」加「預報到哪個時刻」,所以 tile 與 `.bin` 都以 +`{cycle}`(模式執行時刻)+ `{validTime}`(預報有效時刻)定址;`getFrames` 回傳的 +不透明 frame id 由 `FrameTileApi.windFrameParts` 拆成這兩段。`{model}` 是 +`gfs` / `ecmwf`。 -| 方法 | 路徑 | 層級 | 主機 | +| 類別.方法 | 路徑 | 層級 | 主機 | |---|---|---|---| -| `getFrames` | `/api/v2/tiles/wind/list[?model=…]` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | -| `tileUrl` | `/api/v2/tiles/wind/{ts}/{z}/{x}/{y}.webp[?model=…]` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | -| `fetchWindBin` | `/api/v1/wind/{model}/{frame}.bin` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `FrameTileApi.getFrames` | `/api/v2/tiles/wind/{model}/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | +| `FrameTileApi.tileUrl` | `/api/v2/tiles/wind/{model}/{cycle}/{validTime}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `FrameTileApi.fetchWindBin` | `/api/v1/wind/{model}/{cycle}/{validTime}.bin` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | ### 氣象家族(**v5**)—— `core-tnn1` @@ -161,30 +177,30 @@ typhoon)共用同一組形狀:`/api/v5/meteor/{family}` 是最新快照、`/ 時間軸與數值皆為**差量/哨符編碼**,由 `core/network/meteor_decode.dart` 還原: `ts` 是 `[baseSec, Δ, …]`,數值序列中的 `-99` 代表 null(缺值),不是讀數。 -| 方法 | 路徑 | 層級 | +`weather` / `rain` / `lightning` 三家共用同一個參數化的 `MeteorSnapshotApi` +(建構時傳入 `_base`),所以**方法名只有五個,不是每家一組**。颱風的形狀不同, +自成 `MeteorTyphoonApi`。 + +| 類別.方法 | 路徑 | 層級 | |---|---|---| -| `getWeatherStations` | `/api/v5/meteor/weather/station` | `coreExclusiveApi` | -| `getWeatherLatest` | `/api/v5/meteor/weather` | `coreExclusiveApi` | -| `getWeatherList` | `/api/v5/meteor/weather/list` | `coreExclusiveApi` | -| `getWeatherAt` | `/api/v5/meteor/weather/{sec}` | `coreStaticExclusive` | -| `getWeatherTrend` | `/api/v5/meteor/weather/trend/{id}?range=24h\|7d` | `coreExclusiveApi` | -| `getWeatherRealtime` | `/api/v5/meteor/weather/realtime/{lat},{lng}` | `coreExclusiveApi` | -| `getWeatherForecast` | `/api/v5/meteor/weather/forecast/{code}` | `coreExclusiveApi` | -| `getRainStations` | `/api/v5/meteor/rain/station` | `coreExclusiveApi` | -| `getRainLatest` | `/api/v5/meteor/rain` | `coreExclusiveApi` | -| `getRainList` | `/api/v5/meteor/rain/list` | `coreExclusiveApi` | -| `getRainAt` | `/api/v5/meteor/rain/{sec}` | `coreStaticExclusive` | -| `getRainTrend` | `/api/v5/meteor/rain/trend/{id}?range=24h\|7d` | `coreExclusiveApi` | -| `getLightningLatest` | `/api/v5/meteor/lightning` | `coreExclusiveApi` | -| `getLightningList` | `/api/v5/meteor/lightning/list` | `coreExclusiveApi` | -| `getLightningAt` | `/api/v5/meteor/lightning/{sec}` | `coreStaticExclusive` | -| `getTyphoonLatest` | `/api/v5/meteor/typhoon` | `coreExclusiveApi` | -| `getTyphoonTrack` | `/api/v5/meteor/typhoon/track` | `coreExclusiveApi` | -| `getTyphoonPotential` | `/api/v5/meteor/typhoon/potential` | `coreExclusiveApi` | -| `getTyphoonProbability` | `/api/v5/meteor/typhoon/probability` | `coreExclusiveApi` | -| `getTyphoonWarning` | `/api/v5/meteor/typhoon/warning` | `coreExclusiveApi` | -| `getTyphoonKindList` | `/api/v5/meteor/typhoon/{kind}/list` | `coreExclusiveApi` | -| `getTyphoonKindAt` | `/api/v5/meteor/typhoon/{kind}/{sec}` | `coreStaticExclusive` | +| `MeteorSnapshotApi.getStation` | `/api/v5/meteor/{family}/station` | `coreExclusiveApi` | +| `MeteorSnapshotApi.getLatest` | `/api/v5/meteor/{family}` | `coreExclusiveApi` | +| `MeteorSnapshotApi.getList` | `/api/v5/meteor/{family}/list` | `coreExclusiveApi` | +| `MeteorSnapshotApi.getAt` | `/api/v5/meteor/{family}/{sec}` | `coreStaticExclusive` | +| `MeteorSnapshotApi.getTrend` | `/api/v5/meteor/{family}/trend/{id}?range=24h\|7d` | `coreExclusiveApi` | +| `MeteorWeatherApi.getRealtime` | `/api/v5/meteor/weather/realtime/{lat},{lng}` | `coreExclusiveApi` | +| `MeteorWeatherApi.getForecast` | `/api/v5/meteor/weather/forecast/{code}` | `coreExclusiveApi` | +| `MeteorTyphoonApi.getCyclones` | `/api/v5/meteor/typhoon` | `coreExclusiveApi` | +| `MeteorTyphoonApi.getTrack` | `/api/v5/meteor/typhoon/track` | `coreExclusiveApi` | +| `MeteorTyphoonApi.getPotential` | `/api/v5/meteor/typhoon/potential` | `coreExclusiveApi` | +| `MeteorTyphoonApi.getProbability` | `/api/v5/meteor/typhoon/probability` | `coreExclusiveApi` | +| `MeteorTyphoonApi.getWarning` | `/api/v5/meteor/typhoon/warning` | `coreExclusiveApi` | +| `MeteorTyphoonApi.getList` | `/api/v5/meteor/typhoon/{kind}/list` | `coreExclusiveApi` | +| `MeteorTyphoonApi.getAt` | `/api/v5/meteor/typhoon/{kind}/{sec}` | `coreStaticExclusive` | + +`{family}` = `weather` \| `rain` \| `lightning`(`station` 只有前兩家有; +lightning 沒有測站,也沒有 `trend`)。`{kind}` = `track` \| `potential` \| +`probability` \| `warning`(`TyphoonKind.path`,與 enum 名同字)。 > **颱風多颱**:`/`、`/track`、`/potential`、`/probability`、`/warning` 一律 > `{ updated, cyclones: [...] }`;唯一識別是 **`tdNo`**(CWA `CwaTdNo`,未命名 @@ -198,38 +214,45 @@ typhoon)共用同一組形狀:`/api/v5/meteor/{family}` 是最新快照、`/ ### 裝置與通知 —— `core-tnn1` -| 方法 | 路徑 | 層級 | +| 類別.方法 | 路徑 | 層級 | |---|---|---| -| `updateDeviceLocation` | `/api/v2/location/{platform}/{token}/{version}/{lat},{lng}` | `coreExclusiveApi` | -| `getNotify` | `/api/v2/notify/{token}` | `coreExclusiveApi` | -| `setNotify` | `/api/v2/notify/{token}/{channel}/{status}` | `coreExclusiveApi` | +| `LocationApi.updateDeviceLocation` | `/api/v2/location/{platform}/{token}/{version}/{lat},{lng}` | `coreExclusiveApi` | +| `NotifyApi.getNotify` | `/api/v2/notify/{token}` | `coreExclusiveApi` | +| `NotifyApi.setNotify` | `/api/v2/notify/{token}/{channel}/{status}` | `coreExclusiveApi` | ### 舊 server `api-1`(逐步淘汰中) 後端會把端點陸續搬到 `core-tnn1`,這裡會隨之縮減。以下**仍只在 `api-1` 上**, 且都已在 App 中實際使用: -| 方法 | 路徑 | 層級 | 使用處 | +| 類別.方法 | 路徑 | 層級 | 使用處 | |---|---|---|---| -| `getStations` | `/api/v1/trem/station` | `legacyApi` | 強震監視器測站 | -| `getHistoryList` | `/api/v1/dpip/history/list` | `legacyApi` | 事件頁(全國) | -| `getHistoryRegion` | `/api/v1/dpip/history/{region}` | `legacyApi` | 事件頁(鄉鎮) | -| `getRealtimeList` | `/api/v1/dpip/realtime/list` | `legacyApi` | 首頁拖盤收起(全國生效中) | -| `getRealtimeRegion` | `/api/v1/dpip/realtime/{region}` | `legacyApi` | 首頁拖盤收起(鄉鎮生效中) | -| `getRtsAt` | `/api/v2/trem/rts/{sec}` | `legacyApi` | 強震波形回放(時間軸) | - -尚未接上、但端點存在於 `api-1`: +| `TremStationRepositoryImpl.stations` | `/api/v1/trem/station` | `legacyApi` | 強震監視器測站 | +| `EventApi.getHistoryList` | `/api/v1/dpip/history/list` | `legacyApi` | 事件頁(全國) | +| `EventApi.getHistoryRegion` | `/api/v1/dpip/history/{region}` | `legacyApi` | 事件頁(鄉鎮) | +| `EventApi.getRealtimeList` | `/api/v1/dpip/realtime/list` | `legacyApi` | 首頁拖盤收起(全國生效中) | +| `EventApi.getRealtimeRegion` | `/api/v1/dpip/realtime/{region}` | `legacyApi` | 首頁拖盤收起(鄉鎮生效中) | +| `EarthquakeApi.getRtsAt` | `/api/v2/trem/rts/{sec}` | `legacyApi` | 強震波形回放(時間軸) | -| 方法 | 路徑 | 層級 | -|---|---|---| -| `getEvent` | `/api/v1/dpip/event/{id}` | `legacyApi` | +`/api/v1/dpip/event/{id}` 存在於 `api-1`,但 App 裡**沒有任何方法呼叫它** —— +先前這裡列的 `getEvent` 並不存在於程式碼中。 ## 外部(第三方,無區域) -| 方法 | URL | +走 `ApiClient.getAbsolute` / `postAbsolute`:沒有 tier、沒有區域容錯,也不參與 +ETag 重新驗證。 + +| 類別.方法 | URL | |---|---| -| `getReleases` | `https://api.github.com/repos/ExpTechTW/DPIP/releases`(ETag;`per_page=30`) | -| `getRainHourForecast` | `https://exptech.dingbot.tw/api/weather/rainforecast/{code}`(`{code}` = 鄉鎮 3 碼;回應為單 series 信封 `{"<系列名>": [{"start": 秒, "rain": [60 × mm]}]}`;空 series `[]` = 該小時無雨,卡片隱藏) | +| `ChangelogApi.getReleases` | `https://api.github.com/repos/ExpTechTW/DPIP/releases`(ETag;`per_page=30`) | +| `ChangelogApi.getAvatarBytes` | `https://avatars.githubusercontent.com/…`(貢獻者頭像,內容定址故長快取) | +| `RainHourTrendApi.getForecast` | `https://exptech.dingbot.tw/api/weather/rainforecast/{code}`(`{code}` = 鄉鎮 3 碼;回應為單 series 信封 `{"<系列名>": [{"start": 秒, "rain": [60 × mm]}]}`;空 series `[]` = 該小時無雨,卡片隱藏) | +| `ServerStatusApi.getStatus` | `https://status.exptech.dev/api/ds/query`(**POST**,Grafana datasource query;伺服器狀態頁) | +| `CloudflareStatusApi.getComponents` | `https://www.cloudflarestatus.com/api/v2/components.json`(Cloudflare 元件狀態) | +| `HasteApi.upload` | `https://haste.exptech.dev/api/pastes`(**POST**,上傳 App 日誌;回應的 `key` 組成 `https://haste.exptech.dev/`) | + +> **衛星 TLE 目前不打網路。** `TleSource` 有一條遠端更新路徑(`TleFetcher`), +> 但正式碼沒有接線(`fetch` 為 null),實際只讀打包在 `assets/astro/` 的元素集。 ## curl 可用性(2026-08-02 實測,HTTP 狀態碼) From 174b795cc3ace0ba226cf8a9101b237426995330 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 03:18:20 +0800 Subject: [PATCH 07/40] perf(map): cap raster sources at their data's real zoom range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 地圖疊圖只抓資料真實存在的縮放層級,Android 上連續縮放更流暢、也省下白費的流量 Optimization(en-US): map overlays fetch only the zoom levels their data really has, smoothing repeated pinch-zooms on Android and cutting wasted requests --- .../weather/data/frame_tile_repository.dart | 15 ++++- lib/features/weather/weather_providers.dart | 13 +++- lib/shared/map/raster_frame_source.dart | 12 ++++ lib/shared/map/raster_timeline_layer.dart | 10 ++- .../map/raster_source_maxzoom_test.dart | 66 +++++++++++++++++++ .../features/map/raster_timeline_harness.dart | 13 ++++ test/shared/map/map_tile_cache_test.dart | 3 + 7 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 test/features/map/raster_source_maxzoom_test.dart diff --git a/lib/features/weather/data/frame_tile_repository.dart b/lib/features/weather/data/frame_tile_repository.dart index fc32c11fb..ca4a89759 100644 --- a/lib/features/weather/data/frame_tile_repository.dart +++ b/lib/features/weather/data/frame_tile_repository.dart @@ -253,12 +253,21 @@ final class FrameTileRepositoryImpl extends FrameTileRepository final FrameTileApi _api; - /// Highest zoom this overlay publishes tiles for. Radar / satellite / - /// QPESUMS reach 11; the 0.25° wind forecast grids stop at 7 (any deeper is - /// upsampled to nothing new). + /// Highest zoom this overlay publishes tiles for — measured from the live + /// endpoints, not guessed: radar and QPESUMS serve real bytes for z3–12, + /// satellite z0–11, wind z0–11, and everything outside those ranges comes + /// back as the empty 35-byte GIF placeholder. The caps sit **below** the + /// publish range on purpose: tile sizes shrink monotonically past each + /// product's resolution peak (radar / QPESUMS peak at z7, Himawari band 13 + /// is ~2 km/px), so deeper levels are the server resampling — a viewport of + /// round trips per zoom crossing for no new detail. Wind was already 7; + /// the others carried the publish ceiling 11 and now match their data. @override final int maxZoom; + @override + int get sourceMaxZoom => maxZoom; + @override String get tilePathPrefix => '${ApiPaths.tiles}/${_api.path}/'; diff --git a/lib/features/weather/weather_providers.dart b/lib/features/weather/weather_providers.dart index daed6367e..19e7cdcdb 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -33,18 +33,26 @@ List weatherProviders(SharedDeps deps) { value: FrameTileRepositoryImpl( FrameTileApi(deps.apiClient, 'radar'), deps.mapTileWarmer(), + // Publishes z3–12, but the composite's resolution peaks at z7; past + // z8 the server resamples. 8 keeps the picture and stops a pinch + // across z9–11 from costing three viewports of requests per frame. + maxZoom: 8, ), ), Provider.value( value: FrameTileRepositoryImpl( FrameTileApi(deps.apiClient, 'qpesums'), deps.mapTileWarmer(), + // Same shape as radar: publishes z3–12, information ends ~z7. + maxZoom: 8, ), ), Provider.value( value: FrameTileRepositoryImpl( FrameTileApi(deps.apiClient, 'satellite'), deps.mapTileWarmer(), + // Publishes z0–11; band 13 is ~2 km/px so z8 already oversamples. + maxZoom: 8, ), ), // One repository per channel the satellite layer picker offers — each needs @@ -56,12 +64,15 @@ List weatherProviders(SharedDeps deps) { channel: FrameTileRepositoryImpl( FrameTileApi(deps.apiClient, 'satellite', channel: channel.key), deps.mapTileWarmer(), + maxZoom: 8, ), }, ), // One repository per wind forecast model — each needs its own model path on // both the frame list and every tile URL, and its own warmer. The 0.25° - // grids stop publishing at z7. + // grids stop publishing at z7 — and since that is also where the data ends, + // the source cap now matches instead of letting native fetch z8–11 the + // warm path never covered. Provider>.value( value: { for (final model in WindForecastModel.values) diff --git a/lib/shared/map/raster_frame_source.dart b/lib/shared/map/raster_frame_source.dart index 7fd4442da..c61283b5c 100644 --- a/lib/shared/map/raster_frame_source.dart +++ b/lib/shared/map/raster_frame_source.dart @@ -20,6 +20,18 @@ abstract interface class RasterFrameSource { /// Available frame ids, newest first; `Ok([])` when none. Future>> frames(); + /// Highest zoom this overlay's tiles genuinely exist for. + /// + /// Measured from the live endpoints, not guessed: radar / QPESUMS publish + /// real bytes for z3–12 and satellite / wind z0–11 (everything outside is + /// the empty placeholder), but each product's own resolution runs out around + /// z7–8 — deeper levels are the server resampling the same pixels, so a + /// request there costs a full viewport of round trips per zoom crossing and + /// gains no detail. The timeline passes this as the MapLibre source + /// `maxzoom`, so the renderer overzooms the top level instead of fetching + /// placeholders. + int get sourceMaxZoom; + /// XYZ raster tile URL **template** for [frame] (contains `{z}/{x}/{y}`). String tileUrl(String frame); diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index 9990731c2..007535ca9 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -1248,7 +1248,15 @@ abstract class RasterTimelineLayer implements MapLayer { await _ensureSeam(controller); await controller.addSource( _sourceId(id), - RasterSourceProperties(tiles: [source.tileUrl(id)], tileSize: 256), + RasterSourceProperties( + tiles: [source.tileUrl(id)], + tileSize: 256, + // Past this level MapLibre overzooms the top band instead of + // requesting tiles that only come back as the empty placeholder — + // and on Android every avoided request is a platform-thread round + // trip a pinch gesture no longer has to wait behind. + maxzoom: source.sourceMaxZoom.toDouble(), + ), ); await controller.addRasterLayer( _sourceId(id), diff --git a/test/features/map/raster_source_maxzoom_test.dart b/test/features/map/raster_source_maxzoom_test.dart new file mode 100644 index 000000000..8c16e3852 --- /dev/null +++ b/test/features/map/raster_source_maxzoom_test.dart @@ -0,0 +1,66 @@ +/// The tile-pyramid cap reaches the MapLibre source, not just the warmer. +/// +/// `RasterFrameSource.sourceMaxZoom` exists so a pinch past the data's last +/// real zoom level overzooms the top band instead of issuing doomed requests +/// — on Android every one of those is a platform-thread round trip the +/// gesture has to wait behind. This pins the pass-through: whatever the +/// source declares is what the mounted raster source carries as `maxzoom`. +library; + +import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; +import 'package:dpip/features/weather/domain/radar_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'raster_timeline_harness.dart'; + +/// Frame ids newest first, 10 minutes apart — the shape radar's list returns. +List _ids(int count) => [ + for (var i = 0; i < count; i++) (1700000000 + (count - i) * 600).toString(), +]; + +class _CappedRadarRepository extends FakeRasterFrameSource + implements RadarRepository { + _CappedRadarRepository(super.frames); + + @override + String tileUrl(String frame) => + 'https://tiles.example.dev/radar/$frame/{z}/{x}/{y}.webp'; +} + +void main() { + test('mounted radar sources carry the pyramid cap as maxzoom', () async { + final source = _CappedRadarRepository(_ids(9))..sourceMaxZoom = 8; + final layer = RadarMapLayer(source); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + // Index 4 with ringRadius 2 mounts frames 2..6. + await layer.show(controller, frames[4]); + + final mounted = Map.fromEntries( + controller.sourceProperties.entries.where( + (entry) => entry.key.startsWith('radar-src-'), + ), + ); + expect(mounted, isNotEmpty, reason: 'the ring must have mounted sources'); + for (final entry in mounted.entries) { + expect( + entry.value['maxzoom'], + 8.0, + reason: + '${entry.key} must cap at the source pyramid instead of ' + 'requesting placeholder tiles above it', + ); + expect(entry.value['tileSize'], 256); + } + }); + + test('an uncapped fake keeps the old behaviour', () async { + // Guards against the cap accidentally leaking into sources whose data + // really does extend to the camera ceiling: the default stays 22, which + // within the app's z4–z11 camera range means "no cap". + final source = _CappedRadarRepository(_ids(9)); + expect(source.sourceMaxZoom, 22); + }); +} diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index 0633ac767..c5f1a7e77 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -19,6 +19,11 @@ abstract class FakeRasterFrameSource implements RasterFrameSource { final List _frames; + /// What mounted sources should carry as their MapLibre `maxzoom` — 22 so a + /// fake that never cared behaves exactly like the old uncapped mounts. + @override + int sourceMaxZoom = 22; + /// One entry per [warmFrameTiles] call: the frames it was asked to warm. final List> warmed = []; @@ -100,6 +105,9 @@ class RecordingMapController implements MapLibreMapController { /// told to draw, rather than merely that it was told something. final Map> sourceData = {}; + /// Full property JSON of every non-GeoJSON source, by source id. + final Map> sourceProperties = {}; + /// How many times the visible region was asked for. It is a platform /// round-trip, so a scrub that re-derives a rectangle the camera never moved /// is paying for it once per crossed frame. @@ -127,6 +135,11 @@ class RecordingMapController implements MapLibreMapController { } if (data is Map) sourceData[sourceId] = Map.from(data); calls.add('addSource:$sourceId'); + // Raster sources carry no `data`; record the whole property set so tests + // can pin the mount contract (tileSize, maxzoom, …) instead of inferring + // it from renderer behaviour. + final json = properties.toJson(); + if (data == null) sourceProperties[sourceId] = json; } @override diff --git a/test/shared/map/map_tile_cache_test.dart b/test/shared/map/map_tile_cache_test.dart index c660b6591..f0977eb60 100644 --- a/test/shared/map/map_tile_cache_test.dart +++ b/test/shared/map/map_tile_cache_test.dart @@ -56,6 +56,9 @@ final class _TestFrameRepository extends FrameTileRepository { @override int get maxZoom => 11; + @override + int get sourceMaxZoom => maxZoom; + @override String get tilePathPrefix => '/api/v2/tiles/radar/'; From 73f05bc8257c6cd39bb57ca4a8d74c6c3a4bb473 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 05:39:07 +0800 Subject: [PATCH 08/40] docs(eew): record why the box grid decodes inline --- lib/features/earthquake/data/rts_box_grid_source.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/features/earthquake/data/rts_box_grid_source.dart b/lib/features/earthquake/data/rts_box_grid_source.dart index 24a70d770..e758e90df 100644 --- a/lib/features/earthquake/data/rts_box_grid_source.dart +++ b/lib/features/earthquake/data/rts_box_grid_source.dart @@ -10,6 +10,12 @@ import 'package:flutter/services.dart' show rootBundle; /// `Polygon` features, each carrying an integer `ID` property matched against /// `Rts.box`'s keys. Kept out of the pure domain (which only consumes the /// parsed grid) so the domain stays Flutter-free. +/// +/// Deliberately **not** decoded in an isolate, unlike its sibling travel-time +/// table: this asset is 773 bytes compressed / 7 KB inflated (43 polygons, +/// 215 points), so the whole decode lands well under a millisecond, while an +/// isolate spawn costs more than that before any work runs. Measured, not +/// assumed — re-audit if the asset ever grows. class RtsBoxGridSource { const RtsBoxGridSource(); From 8270704a808fb525ca60db8aba301bf244a44675 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 06:58:34 +0800 Subject: [PATCH 09/40] docs(eew): record why the historical RTS decode stays inline --- lib/features/earthquake/data/earthquake_api.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/features/earthquake/data/earthquake_api.dart b/lib/features/earthquake/data/earthquake_api.dart index 917305c0b..3c3240924 100644 --- a/lib/features/earthquake/data/earthquake_api.dart +++ b/lib/features/earthquake/data/earthquake_api.dart @@ -58,6 +58,11 @@ class EarthquakeApi { // doesn't auto-decode it — the caller gets a raw JSON string instead of // a Map. Decode it here so this method's return shape matches the rest // of [EarthquakeApi] regardless of the host's content-type quirk. + // + // Inline on purpose, not an isolate: the snapshot is ~5 KB (~111 + // stations, re-measured 2026-08-24 against api-1), so the decode is tens + // of microseconds even at replay's 1 Hz — an isolate spawn would cost + // more than it saves. return data is String ? jsonDecode(data) : data; } From ccbc0f0d232b6c8bf87fc96199e28fdbe9e6d5b2 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 09:35:23 +0800 Subject: [PATCH 10/40] feat(rain): read the ramp against CWA bands with two threshold tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 雨量圖改用中央氣象署階梯色階並預設顯示過去一小時,長時窗自動改用大間距門檻 New(en-US): the rainfall map switches to the CWA banded scale and defaults to the past hour, widening its thresholds for long windows --- .../presentation/layers/rain_color_scale.dart | 84 ++++++ .../map/presentation/layers/rain_layer.dart | 108 ++++++-- .../layers/weather_station_layer.dart | 82 +++++- lib/l10n/app_en.arb | 14 +- lib/l10n/app_fil.arb | 5 +- lib/l10n/app_id.arb | 5 +- lib/l10n/app_ja.arb | 5 +- lib/l10n/app_ko.arb | 5 +- lib/l10n/app_th.arb | 5 +- lib/l10n/app_vi.arb | 5 +- lib/l10n/app_yue.arb | 5 +- lib/l10n/app_zh.arb | 5 +- lib/l10n/app_zh_Hans.arb | 5 +- lib/l10n/app_zh_Hant_HK.arb | 5 +- lib/l10n/app_zh_TW.arb | 5 +- lib/l10n/gen/app_localizations.dart | 18 ++ lib/l10n/gen/app_localizations_en.dart | 9 + lib/l10n/gen/app_localizations_fil.dart | 9 + lib/l10n/gen/app_localizations_id.dart | 9 + lib/l10n/gen/app_localizations_ja.dart | 9 + lib/l10n/gen/app_localizations_ko.dart | 9 + lib/l10n/gen/app_localizations_th.dart | 9 + lib/l10n/gen/app_localizations_vi.dart | 9 + lib/l10n/gen/app_localizations_yue.dart | 9 + lib/l10n/gen/app_localizations_zh.dart | 36 +++ lib/shared/color_hex.dart | 17 ++ lib/shared/widgets/map_chip_button.dart | 42 ++- lib/shared/widgets/map_color_legend.dart | 89 +++++- test/features/map/rain_color_scale_test.dart | 256 ++++++++++++++++++ .../features/map/raster_timeline_harness.dart | 10 + 30 files changed, 814 insertions(+), 69 deletions(-) create mode 100644 lib/features/map/presentation/layers/rain_color_scale.dart create mode 100644 test/features/map/rain_color_scale_test.dart diff --git a/lib/features/map/presentation/layers/rain_color_scale.dart b/lib/features/map/presentation/layers/rain_color_scale.dart new file mode 100644 index 000000000..c621cacdc --- /dev/null +++ b/lib/features/map/presentation/layers/rain_color_scale.dart @@ -0,0 +1,84 @@ +/// Rainfall accumulation colour scales (CWA banded ramp). +library; + +import 'package:dpip/features/weather/domain/rain_interval.dart'; + +/// Which set of thresholds the rainfall ramp is read against. +/// +/// The colours are identical in both; only the mm boundaries move. One hour of +/// rain and three days of rain differ by two orders of magnitude, so a single +/// table either flattens every short window to grey or saturates every long one +/// to pink. Two tables keep the same 17 bands legible at both ends. +enum RainColorScale { + /// 1–300 mm. Short windows: a typhoon hour tops out near 100 mm. + fine, + + /// 10–1500 mm. Multi-day totals: Morakot's 2009 maximum was ~2900 mm/3 d. + coarse; + + /// The scale that suits [interval] when the user has not chosen one. + /// + /// The split is at 6 h: 3 h of rain reaching 300 mm is already a records-level + /// event, while 6 h routinely passes it in a typhoon. + static RainColorScale defaultFor(RainInterval interval) => switch (interval) { + RainInterval.now || + RainInterval.min10 || + RainInterval.hour1 || + RainInterval.hour3 => RainColorScale.fine, + RainInterval.hour6 || + RainInterval.hour12 || + RainInterval.hour24 || + RainInterval.day2 || + RainInterval.day3 => RainColorScale.coarse, + }; + + /// Ascending `(mm, hex)` band floors, lowest first. + /// + /// Read as **steps, not a gradient**: a value takes the colour of the last + /// floor it is at or above, so 99 mm is the same red as 90 mm. That is what + /// the CWA scale means — a band is a category, and interpolating across it + /// invents readings the observation never made. + /// + /// The first entry is the below-threshold band (dry / trace), which is why + /// there are 17 entries for 16 printed boundaries. + List<(double, String)> get stops => switch (this) { + RainColorScale.fine => const [ + (0, '#c2c2c2'), + (1, '#a0fffa'), + (2, '#00cdff'), + (6, '#0096ff'), + (10, '#0069ff'), + (15, '#329600'), + (20, '#32ff00'), + (30, '#ffff00'), + (40, '#ffc800'), + (50, '#ff9600'), + (70, '#ff0000'), + (90, '#c80000'), + (110, '#a00000'), + (130, '#96009b'), + (150, '#c800d2'), + (200, '#ff00f0'), + (300, '#ffc8ff'), + ], + RainColorScale.coarse => const [ + (0, '#c2c2c2'), + (10, '#a0fffa'), + (20, '#00cdff'), + (60, '#0096ff'), + (100, '#0069ff'), + (150, '#329600'), + (200, '#32ff00'), + (300, '#ffff00'), + (400, '#ffc800'), + (500, '#ff9600'), + (600, '#ff0000'), + (700, '#c80000'), + (800, '#a00000'), + (900, '#96009b'), + (1000, '#c800d2'), + (1200, '#ff00f0'), + (1500, '#ffc8ff'), + ], + }; +} diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index 2b6321cd3..8db87a124 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -4,6 +4,7 @@ library; import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/features/map/presentation/layers/weather_station_layer.dart'; +import 'package:dpip/features/map/presentation/layers/rain_color_scale.dart'; import 'package:dpip/features/weather/domain/rain_interval.dart'; import 'package:dpip/features/weather/domain/rain_snapshot.dart'; import 'package:dpip/features/weather/domain/rain_trend.dart'; @@ -29,14 +30,38 @@ extension RainIntervalL10n on RainInterval { }; } +/// Localised labels for [RainColorScale]. +extension RainColorScaleL10n on RainColorScale { + String label(AppLocalizations l10n) => switch (this) { + RainColorScale.fine => l10n.rainScaleFine, + RainColorScale.coarse => l10n.rainScaleCoarse, + }; +} + /// Shares [WeatherStationLayer]'s dots/sheet/trend machinery; only the value /// source (the accumulation window) and its chrome differ. class RainMapLayer extends WeatherStationLayer { RainMapLayer(super.repository); - /// Selected accumulation window — default matches legacy (`now` = 今日). - final ValueNotifier interval = ValueNotifier(RainInterval.now); + /// Selected accumulation window. + /// + /// One hour, not `now`: the day-so-far total answers "has it rained", which + /// the forecast already says, while the last hour answers "is it raining + /// hard right now" — the question a rainfall map is opened for. + final ValueNotifier interval = ValueNotifier( + RainInterval.hour1, + ); + + /// Threshold table the ramp is read against. + /// + /// Changing the window re-suggests the scale that suits it, and an explicit + /// choice holds only until the next window change. Sticking to a manual + /// choice forever would silently flatten a 3-day total to one grey blob for + /// anyone who once picked the fine scale to inspect an hour. + final ValueNotifier colorScale = ValueNotifier( + RainColorScale.defaultFor(RainInterval.hour1), + ); @override String get id => 'rain'; @@ -60,21 +85,16 @@ class RainMapLayer @override bool get chartBars => true; - /// Legacy precipitation colour ramp (mm). + /// CWA banded precipitation scale (mm) at the selected [colorScale]. @override List<(double, String)> get colorStops => [ - (0, '#c2c2c2'.vision), - (10, '#9cfcff'.vision), - (30, '#059bff'.vision), - (50, '#39ff03'.vision), - (100, '#fffb03'.vision), - (200, '#ff9500'.vision), - (300, '#ff0000'.vision), - (500, '#fb00ff'.vision), - (1000, '#960099'.vision), - (2000, '#000000'.vision), + for (final (at, hex) in colorScale.value.stops) (at, hex.vision), ]; + /// The published scale is a table of categories, not a gradient. + @override + bool get bandedColors => true; + @override double? valueOf(RainObservation observation) => interval.value.valueOf(observation); @@ -106,7 +126,7 @@ class RainMapLayer ) => value > 0 || zoom > 8; @override - Listenable get chromeListenable => interval; + Listenable get chromeListenable => Listenable.merge([interval, colorScale]); @override Widget? legendHeader(BuildContext context) => Text( @@ -121,16 +141,36 @@ class RainMapLayer } /// Switches the accumulation window and refreshes dots + labels in place. + /// + /// The scale follows: a window change is the moment an explicit scale choice + /// stops being informed, because it was made about a different range. Future setInterval(RainInterval next) async { if (interval.value == next) return; interval.value = next; + colorScale.value = RainColorScale.defaultFor(next); + await _repaint(); + } + + /// Switches the threshold table, keeping the window. + Future setColorScale(RainColorScale next) async { + if (colorScale.value == next) return; + colorScale.value = next; + await _repaint(); + } + + /// Re-pushes the source and the value ramp after a window/scale change. + /// + /// The dots carry their value in the GeoJSON but take their colour from the + /// layer's paint expression, so a scale change has to re-assert the ramp too + /// — the feature data alone is unchanged and would repaint identically. + Future _repaint() async { final map = controller; - if (map != null) { - try { - await map.setGeoJsonSource(sourceId, geoJson); - } catch (_) { - // Source gone (layer torn down) — next [render] rebuilds it. - } + if (map == null) return; + try { + await map.setGeoJsonSource(sourceId, geoJson); + await applyColorRamp(map); + } catch (_) { + // Source gone (layer torn down) — next [render] rebuilds it. } } @@ -146,18 +186,28 @@ class RainMapLayer final l10n = AppLocalizations.of(context); final colors = Theme.of(context).colorScheme; return ListenableBuilder( - listenable: Listenable.merge([interval, showTownLabels, showTerrain]), + listenable: Listenable.merge([ + interval, + colorScale, + showTownLabels, + showTerrain, + ]), builder: (context, _) { final current = interval.value; + final scale = colorScale.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), style: MapChipButton.menuStyle(context), builder: (context, controller, _) => MapChipButton( icon: Icons.timelapse_outlined, + // The window changes what every dot on the map means, so it is read + // far more often than it is set — a bare icon made the answer cost + // a menu open. Same chip affordance and height as every other + // layer's menu, so the compass (parked under the chip band) lines + // up across layers. + label: current.label(l10n), tooltip: l10n.rainIntervalMenu, - // Same chip affordance and height as every other layer's menu, so - // the compass (parked under the chip band) lines up across layers. - active: current != RainInterval.now, + active: current != RainInterval.hour1, onTap: () => controller.isOpen ? controller.close() : controller.open(), ), @@ -180,6 +230,16 @@ class RainMapLayer : null, child: Text(option.label(l10n)), ), + const MapMenuDivider(), + SectionHeader(l10n.rainScaleSection), + for (final option in RainColorScale.values) + MenuItemButton( + onPressed: () => setColorScale(option), + trailingIcon: option == scale + ? Icon(Icons.check, size: 18, color: colors.primary) + : null, + child: Text(option.label(l10n)), + ), ], ), ], diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index 4702e07e0..c16d18a7c 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -62,6 +62,16 @@ abstract class WeatherStationLayer< /// value colour and the legend, so a second pass would compound on all three. List<(double, String)> get colorStops; + /// Whether [colorStops] are **band floors** rather than gradient anchors. + /// + /// Continuous fields (temperature, pressure, humidity) read as a gradient: a + /// value between two stops genuinely lies between two colours. Accumulations + /// do not — the published rainfall scale is a table of categories, and a dot + /// blended halfway between the 70 mm and 90 mm bands claims a precision the + /// band structure denies. Banded layers get a MapLibre `step`, [stepColor] + /// in the sheet, and a hard-edged legend, so all three agree. + bool get bandedColors => false; + /// Whether to draw the value-coloured dot. A subclass may replace it with its /// own symbology (e.g. wind arrows) by returning false. @protected @@ -151,13 +161,7 @@ abstract class WeatherStationLayer< await controller.addCircleLayer( _sourceId, _circleId, - CircleLayerProperties( - circleColor: _colorExpression(), - circleRadius: 6, - circleStrokeColor: _strokeColor, - circleStrokeWidth: 1, - circleOpacity: 0.9, - ), + _circleProperties(), // Non-interactive: we do our own nearest-station math in onMapTap and // want EVERY tap via map#onMapClick — an interactive layer would eat an // on-dot tap as feature#onTap (unhandled) so the station never selects. @@ -224,7 +228,11 @@ abstract class WeatherStationLayer< @override Widget buildLegend(BuildContext context) { final header = legendHeader(context); - final scale = ColorScaleLegend(stops: colorStops, unit: unit); + final scale = ColorScaleLegend( + stops: colorStops, + unit: unit, + banded: bandedColors, + ); final child = header == null ? scale : Column( @@ -282,7 +290,10 @@ abstract class WeatherStationLayer< Color? valueColor(String id) { final observation = observationOf(id); final value = observation == null ? null : valueOf(observation); - return value == null ? null : rampColor(colorStops, value); + if (value == null) return null; + return bandedColors + ? stepColor(colorStops, value) + : rampColor(colorStops, value); } @override @@ -371,12 +382,53 @@ abstract class WeatherStationLayer< return {'type': 'FeatureCollection', 'features': features}; } - List _colorExpression() => [ - 'interpolate', - ['linear'], - ['get', 'value'], - for (final (at, color) in colorStops) ...[at, color], - ]; + /// The dot's complete look. One definition, used by both the initial mount + /// and every later re-assert. + /// + /// It has to be complete: `setLayerProperties` defaults to `skipNulls: false` + /// and then assigns *every* field of the layer type, so a partial update + /// silently resets the ones it omits — a colour-only re-assert shrank these + /// dots to MapLibre's default radius and erased their white outline. + CircleLayerProperties _circleProperties() => CircleLayerProperties( + circleColor: _colorExpression(), + circleRadius: 6, + circleStrokeColor: _strokeColor, + circleStrokeWidth: 1, + circleOpacity: 0.9, + ); + + /// Re-asserts the value ramp on the already-mounted dot layer. + /// + /// A subclass whose [colorStops] depend on runtime state (the rainfall scale) + /// has to push the new expression itself: the GeoJSON carries values, not + /// colours, so re-setting the source alone repaints the identical picture. + /// Silent when the layer is not mounted or does not draw dots — the next + /// [render] builds it with the current ramp either way. + @protected + Future applyColorRamp(MapLibreMapController controller) async { + if (!drawCircle) return; + await controller.setLayerProperties(_circleId, _circleProperties()); + } + + List _colorExpression() { + if (!bandedColors) { + return [ + 'interpolate', + ['linear'], + ['get', 'value'], + for (final (at, color) in colorStops) ...[at, color], + ]; + } + // `step` takes the below-first-stop colour as its default argument, so the + // first stop supplies the fallback and only the rest are boundaries. + final stops = colorStops; + return [ + 'step', + ['get', 'value'], + stops.first.$2, + for (final (at, color) in stops.skip(1)) ...[at, color], + ]; + } Future _removeFromMap(MapLibreMapController controller) async { // Layers must go before their source; tolerate any that aren't on the map. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f37be1347..224c7c35a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3854,5 +3854,17 @@ "description": "Shown when a debug dump could not be uploaded" }, "statusLegendUnprobed": "Not yet probed", - "statusLegendUnsupported": "Not offered" + "statusLegendUnsupported": "Not offered", + "rainScaleSection": "Colour scale", + "rainScaleFine": "Fine", + "rainScaleCoarse": "Coarse", + "@rainScaleSection": { + "description": "Menu section header for the rainfall colour-scale interval choice" + }, + "@rainScaleFine": { + "description": "Rainfall colour scale option: close-spaced thresholds (1-300 mm), for short accumulation windows" + }, + "@rainScaleCoarse": { + "description": "Rainfall colour scale option: wide-spaced thresholds (10-1500 mm), for multi-day totals" + } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 1674f8ad3..4ecbe9a0c 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "Kopyahin ulit", "dumpUploadFailed": "Nabigong mag-upload", "statusLegendUnprobed": "Hindi pa nasuri", - "statusLegendUnsupported": "Hindi suportado" + "statusLegendUnsupported": "Hindi suportado", + "rainScaleSection": "Antas ng kulay", + "rainScaleFine": "Pino", + "rainScaleCoarse": "Magaspang" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index cbfffcf4a..00fc4c152 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "Salin lagi", "dumpUploadFailed": "Gagal mengunggah", "statusLegendUnprobed": "Belum diperiksa", - "statusLegendUnsupported": "Tidak tersedia" + "statusLegendUnsupported": "Tidak tersedia", + "rainScaleSection": "Skala warna", + "rainScaleFine": "Halus", + "rainScaleCoarse": "Kasar" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 7101eaaaf..79a253e5c 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "もう一度コピー", "dumpUploadFailed": "アップロードに失敗しました", "statusLegendUnprobed": "未探知", - "statusLegendUnsupported": "非対応" + "statusLegendUnsupported": "非対応", + "rainScaleSection": "色階の間隔", + "rainScaleFine": "細かい", + "rainScaleCoarse": "粗い" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index b46e9f804..20b94c07b 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "다시 복사", "dumpUploadFailed": "업로드하지 못했습니다", "statusLegendUnprobed": "탐지 안 됨", - "statusLegendUnsupported": "미지원" + "statusLegendUnsupported": "미지원", + "rainScaleSection": "색상 간격", + "rainScaleFine": "좁게", + "rainScaleCoarse": "넓게" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 8f90516ea..750418f88 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "คัดลอกอีกครั้ง", "dumpUploadFailed": "อัปโหลดไม่สำเร็จ", "statusLegendUnprobed": "ยังไม่ตรวจ", - "statusLegendUnsupported": "ไม่รองรับ" + "statusLegendUnsupported": "ไม่รองรับ", + "rainScaleSection": "ช่วงระดับสี", + "rainScaleFine": "ละเอียด", + "rainScaleCoarse": "หยาบ" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index fdf68f43d..da08005f9 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "Sao chép lại", "dumpUploadFailed": "Tải lên thất bại", "statusLegendUnprobed": "Chưa dò", - "statusLegendUnsupported": "Không có" + "statusLegendUnsupported": "Không có", + "rainScaleSection": "Thang màu", + "rainScaleFine": "Mịn", + "rainScaleCoarse": "Thô" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 8dcf071fc..1c068f20b 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "再複製一次", "dumpUploadFailed": "上載失敗,請稍後再試", "statusLegendUnprobed": "未探測", - "statusLegendUnsupported": "唔支援" + "statusLegendUnsupported": "唔支援", + "rainScaleSection": "色階間距", + "rainScaleFine": "小間距", + "rainScaleCoarse": "大間距" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b2ec96a06..13624956e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1942,5 +1942,8 @@ "dumpCopyAgain": "再複製一次", "dumpUploadFailed": "上傳失敗,請稍後再試", "statusLegendUnprobed": "未探測", - "statusLegendUnsupported": "不支援" + "statusLegendUnsupported": "不支援", + "rainScaleSection": "色階間距", + "rainScaleFine": "小間距", + "rainScaleCoarse": "大間距" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 3c5c51073..468da3694 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "再复制一次", "dumpUploadFailed": "上传失败,请稍后再试", "statusLegendUnprobed": "未探测", - "statusLegendUnsupported": "不支持" + "statusLegendUnsupported": "不支持", + "rainScaleSection": "色阶间距", + "rainScaleFine": "小间距", + "rainScaleCoarse": "大间距" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 3fe7caf2d..35c7e2697 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "再複製一次", "dumpUploadFailed": "上載失敗,請稍後再試", "statusLegendUnprobed": "未探測", - "statusLegendUnsupported": "不支援" + "statusLegendUnsupported": "不支援", + "rainScaleSection": "色階間距", + "rainScaleFine": "小間距", + "rainScaleCoarse": "大間距" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 6572933c2..72db6601c 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1950,5 +1950,8 @@ "dumpCopyAgain": "再複製一次", "dumpUploadFailed": "上傳失敗,請稍後再試", "statusLegendUnprobed": "未探測", - "statusLegendUnsupported": "不支援" + "statusLegendUnsupported": "不支援", + "rainScaleSection": "色階間距", + "rainScaleFine": "小間距", + "rainScaleCoarse": "大間距" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index dcad028fd..e7a69845a 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6082,6 +6082,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Not offered'** String get statusLegendUnsupported; + + /// Menu section header for the rainfall colour-scale interval choice + /// + /// In en, this message translates to: + /// **'Colour scale'** + String get rainScaleSection; + + /// Rainfall colour scale option: close-spaced thresholds (1-300 mm), for short accumulation windows + /// + /// In en, this message translates to: + /// **'Fine'** + String get rainScaleFine; + + /// Rainfall colour scale option: wide-spaced thresholds (10-1500 mm), for multi-day totals + /// + /// In en, this message translates to: + /// **'Coarse'** + String get rainScaleCoarse; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index f076ce13a..feead8756 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3196,4 +3196,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get statusLegendUnsupported => 'Not offered'; + + @override + String get rainScaleSection => 'Colour scale'; + + @override + String get rainScaleFine => 'Fine'; + + @override + String get rainScaleCoarse => 'Coarse'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 5c66846f1..567744487 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3212,4 +3212,13 @@ class AppLocalizationsFil extends AppLocalizations { @override String get statusLegendUnsupported => 'Hindi suportado'; + + @override + String get rainScaleSection => 'Antas ng kulay'; + + @override + String get rainScaleFine => 'Pino'; + + @override + String get rainScaleCoarse => 'Magaspang'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 0ae6f08ce..3c4617076 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3206,4 +3206,13 @@ class AppLocalizationsId extends AppLocalizations { @override String get statusLegendUnsupported => 'Tidak tersedia'; + + @override + String get rainScaleSection => 'Skala warna'; + + @override + String get rainScaleFine => 'Halus'; + + @override + String get rainScaleCoarse => 'Kasar'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 6eddc7366..047a34937 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3141,4 +3141,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get statusLegendUnsupported => '非対応'; + + @override + String get rainScaleSection => '色階の間隔'; + + @override + String get rainScaleFine => '細かい'; + + @override + String get rainScaleCoarse => '粗い'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index cbe9175f4..de58f35a3 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3141,4 +3141,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get statusLegendUnsupported => '미지원'; + + @override + String get rainScaleSection => '색상 간격'; + + @override + String get rainScaleFine => '좁게'; + + @override + String get rainScaleCoarse => '넓게'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index b6afa23cc..6b46432df 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3189,4 +3189,13 @@ class AppLocalizationsTh extends AppLocalizations { @override String get statusLegendUnsupported => 'ไม่รองรับ'; + + @override + String get rainScaleSection => 'ช่วงระดับสี'; + + @override + String get rainScaleFine => 'ละเอียด'; + + @override + String get rainScaleCoarse => 'หยาบ'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 5ee378d05..5987676de 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3196,4 +3196,13 @@ class AppLocalizationsVi extends AppLocalizations { @override String get statusLegendUnsupported => 'Không có'; + + @override + String get rainScaleSection => 'Thang màu'; + + @override + String get rainScaleFine => 'Mịn'; + + @override + String get rainScaleCoarse => 'Thô'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index acc8230e8..3edcd5f30 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3126,4 +3126,13 @@ class AppLocalizationsYue extends AppLocalizations { @override String get statusLegendUnsupported => '唔支援'; + + @override + String get rainScaleSection => '色階間距'; + + @override + String get rainScaleFine => '小間距'; + + @override + String get rainScaleCoarse => '大間距'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 223efb74e..df109c2c8 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3126,6 +3126,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get statusLegendUnsupported => '不支援'; + + @override + String get rainScaleSection => '色階間距'; + + @override + String get rainScaleFine => '小間距'; + + @override + String get rainScaleCoarse => '大間距'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6249,6 +6258,15 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get statusLegendUnsupported => '不支持'; + + @override + String get rainScaleSection => '色阶间距'; + + @override + String get rainScaleFine => '小间距'; + + @override + String get rainScaleCoarse => '大间距'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9372,6 +9390,15 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get statusLegendUnsupported => '不支援'; + + @override + String get rainScaleSection => '色階間距'; + + @override + String get rainScaleFine => '小間距'; + + @override + String get rainScaleCoarse => '大間距'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -12495,4 +12522,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get statusLegendUnsupported => '不支援'; + + @override + String get rainScaleSection => '色階間距'; + + @override + String get rainScaleFine => '小間距'; + + @override + String get rainScaleCoarse => '大間距'; } diff --git a/lib/shared/color_hex.dart b/lib/shared/color_hex.dart index 97d4d31b0..bf4252c5f 100644 --- a/lib/shared/color_hex.dart +++ b/lib/shared/color_hex.dart @@ -19,6 +19,23 @@ Color? colorFromHexRgb(String hex) { return value == null ? null : Color(0xFF000000 | value); } +/// [value] read against a **banded** `(at, hexColour)` ramp — the Dart twin of +/// the `step` expression handed to MapLibre. +/// +/// A value takes the colour of the last stop it is at or above; below the first +/// stop it takes the first stop's colour. Unlike [rampColor] no colour is ever +/// invented between two stops, which is the point: on a categorical scale (the +/// CWA rainfall bands) a blend would render a reading that no band defines. +Color? stepColor(List<(double, String)> stops, double value) { + if (stops.isEmpty) return null; + var chosen = stops.first.$2; + for (final (at, hex) in stops) { + if (value < at) break; + chosen = hex; + } + return colorFromHexRgb(chosen); +} + /// [value] interpolated on a `(at, hexColour)` ramp — the Dart twin of the /// `interpolate` expression handed to MapLibre, so a value-coloured dot on the /// map and its reading in the sheet agree by construction rather than by two diff --git a/lib/shared/widgets/map_chip_button.dart b/lib/shared/widgets/map_chip_button.dart index de29428e4..44a87d856 100644 --- a/lib/shared/widgets/map_chip_button.dart +++ b/lib/shared/widgets/map_chip_button.dart @@ -18,6 +18,7 @@ class MapChipButton extends StatelessWidget { required this.tooltip, required this.active, required this.onTap, + this.label, }); /// Whether the menu's settings differ from the defaults: tints the icon @@ -27,6 +28,17 @@ class MapChipButton extends StatelessWidget { /// Glyph shown on the chip (outlined variant by convention). final IconData icon; + /// Optional current-value text beside the glyph. + /// + /// For a menu whose selection changes what the whole map means (the rainfall + /// accumulation window), where reading the current value is far more frequent + /// than changing it. The chip grows sideways only — its height is what the + /// compass parks under, so it must not move. + /// + /// A labelled chip drops the [active] marker dot: the label already says what + /// the dot was hinting at, and the dot would sit on top of the text. + final String? label; + final String tooltip; final VoidCallback onTap; @@ -75,13 +87,33 @@ class MapChipButton extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.all(AppSpacing.sm), - child: Icon( - icon, - size: 22, - color: active ? colors.primary : colors.onSurfaceVariant, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 22, + color: active + ? colors.primary + : colors.onSurfaceVariant, + ), + if (label case final text?) ...[ + const SizedBox(width: AppSpacing.xs), + Text( + text, + style: Theme.of(context).textTheme.labelLarge + ?.copyWith( + height: 1, + color: active + ? colors.primary + : colors.onSurfaceVariant, + ), + ), + ], + ], ), ), - if (active) + if (active && label == null) Positioned( top: 3, right: 3, diff --git a/lib/shared/widgets/map_color_legend.dart b/lib/shared/widgets/map_color_legend.dart index 67eba80d5..0d6218c98 100644 --- a/lib/shared/widgets/map_color_legend.dart +++ b/lib/shared/widgets/map_color_legend.dart @@ -54,7 +54,12 @@ class MapLegendCard extends StatelessWidget { /// The unit, when present, is always shown **below** the scale ([unit]); it is /// never appended to every value label — a number column stays numbers. class ColorScaleLegend extends StatelessWidget { - const ColorScaleLegend({super.key, required this.stops, this.unit}); + const ColorScaleLegend({ + super.key, + required this.stops, + this.unit, + this.banded = false, + }); /// Ascending value → hex colour pairs (same order as MapLibre ramps), in /// whatever colour the layer actually paints — corrected, or raster-exempt, @@ -64,6 +69,15 @@ class ColorScaleLegend extends StatelessWidget { /// Unit shown below the scale, e.g. `m/s` or `°C`. final String? unit; + /// Draw hard-edged bands instead of a gradient. + /// + /// A banded scale is a table of categories, so each stop paints a solid cell + /// and its value is printed **on the boundary** it opens — the reading a + /// number marks is where one band ends and the next begins, not the middle of + /// a swatch. The lowest stop is the below-threshold band and prints no + /// number, which is why N stops show N-1 labels. + final bool banded; + static const double _cell = 14; static const double _swatch = 8; static const double _corner = 4; @@ -95,25 +109,40 @@ class ColorScaleLegend extends StatelessWidget { Container( width: _swatch, height: height, + clipBehavior: banded ? Clip.antiAlias : Clip.none, decoration: BoxDecoration( borderRadius: BorderRadius.circular(_corner), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: swatchColors, - ), + gradient: banded + ? null + : LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: swatchColors, + ), ), + child: banded + ? Column( + children: [ + for (final color in swatchColors) + Expanded(child: ColoredBox(color: color)), + ], + ) + : null, ), const SizedBox(width: AppSpacing.sm), SizedBox( height: height, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final stop in rows) - Expanded(child: Text(_label(stop.$1), style: labelStyle)), - ], - ), + child: banded + ? _BandBoundaryLabels(rows: rows, style: labelStyle) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final stop in rows) + Expanded( + child: Text(_label(stop.$1), style: labelStyle), + ), + ], + ), ), ], ), @@ -334,3 +363,37 @@ class _LineSwatchPainter extends CustomPainter { old.casingWidth != casingWidth || old.dash != dash; } + +/// The number column of a banded scale. +/// +/// Each label is centred on the line between two bands rather than inside one, +/// which is how a published rainfall scale reads: `70` is the point the band +/// changes, not a sample from within it. [rows] is strongest-first (top-down), +/// so row *i* opens the boundary one cell below the top of its own band, and +/// the last row — the below-threshold band — opens nothing and is skipped. +class _BandBoundaryLabels extends StatelessWidget { + const _BandBoundaryLabels({required this.rows, required this.style}); + + final List rows; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + const cell = ColorScaleLegend._cell; + return Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < rows.length - 1; i++) + Positioned( + top: (i + 1) * cell - cell / 2, + left: 0, + height: cell, + child: Align( + alignment: Alignment.centerLeft, + child: Text(ColorScaleLegend._label(rows[i].$1), style: style), + ), + ), + ], + ); + } +} diff --git a/test/features/map/rain_color_scale_test.dart b/test/features/map/rain_color_scale_test.dart new file mode 100644 index 000000000..fb09c43d8 --- /dev/null +++ b/test/features/map/rain_color_scale_test.dart @@ -0,0 +1,256 @@ +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/map/presentation/layers/rain_color_scale.dart'; +import 'package:dpip/features/map/presentation/layers/rain_layer.dart'; +import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; +import 'package:dpip/features/weather/domain/rain_interval.dart'; +import 'package:dpip/features/weather/domain/rain_snapshot.dart'; +import 'package:dpip/features/weather/domain/rain_trend.dart'; +import 'package:dpip/features/weather/domain/weather_station.dart'; +import 'package:dpip/shared/color_hex.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'raster_timeline_harness.dart'; + +void main() { + group('the published rainfall scale', () { + test('both tables carry the same 17 bands in the same order', () { + final fine = RainColorScale.fine.stops; + final coarse = RainColorScale.coarse.stops; + + expect(fine, hasLength(17)); + expect(coarse, hasLength(17)); + expect( + [for (final (_, hex) in coarse) hex], + [for (final (_, hex) in fine) hex], + reason: + 'the two scales differ only in where the boundaries fall — a ' + 'colour that means "heavy" on one and "moderate" on the other ' + 'would make the legend unreadable across a window change', + ); + }); + + test('thresholds ascend strictly, so `step` has a total order', () { + for (final scale in RainColorScale.values) { + final values = [for (final (at, _) in scale.stops) at]; + expect( + values, + orderedEquals(values.toList()..sort()), + reason: '$scale is not ascending', + ); + expect( + values.toSet(), + hasLength(values.length), + reason: '$scale has a duplicate boundary', + ); + } + }); + + test('the first band is the dry band at zero', () { + for (final scale in RainColorScale.values) { + expect(scale.stops.first.$1, 0, reason: '$scale'); + } + }); + + test('every colour parses', () { + for (final scale in RainColorScale.values) { + for (final (at, hex) in scale.stops) { + expect( + colorFromHexRgb(hex), + isNotNull, + reason: '$scale $at mm -> $hex', + ); + } + } + }); + + test('the fine table reaches 300 mm and the coarse one 1500 mm', () { + expect(RainColorScale.fine.stops.last.$1, 300); + expect(RainColorScale.coarse.stops.last.$1, 1500); + }); + }); + + group('scale suggested for a window', () { + test('short windows get the fine table', () { + for (final interval in [ + RainInterval.now, + RainInterval.min10, + RainInterval.hour1, + RainInterval.hour3, + ]) { + expect(RainColorScale.defaultFor(interval), RainColorScale.fine); + } + }); + + test('six hours and longer get the coarse table', () { + for (final interval in [ + RainInterval.hour6, + RainInterval.hour12, + RainInterval.hour24, + RainInterval.day2, + RainInterval.day3, + ]) { + expect(RainColorScale.defaultFor(interval), RainColorScale.coarse); + } + }); + + test('every window is covered', () { + for (final interval in RainInterval.values) { + expect(() => RainColorScale.defaultFor(interval), returnsNormally); + } + }); + }); + + group('the rainfall layer', () { + RainMapLayer layer() => RainMapLayer(_StubRainRepository()); + + test('opens on the last hour, on the fine scale', () { + final rain = layer(); + expect(rain.interval.value, RainInterval.hour1); + expect(rain.colorScale.value, RainColorScale.fine); + expect(rain.colorStops.last.$1, 300); + }); + + test('paints bands, not a gradient', () { + expect(layer().bandedColors, isTrue); + }); + + test('changing the window re-suggests the scale for it', () async { + final rain = layer(); + await rain.setInterval(RainInterval.day3); + expect(rain.colorScale.value, RainColorScale.coarse); + expect(rain.colorStops.last.$1, 1500); + + await rain.setInterval(RainInterval.min10); + expect(rain.colorScale.value, RainColorScale.fine); + }); + + test('an explicit scale survives until the window moves', () async { + final rain = layer(); + await rain.setInterval(RainInterval.hour24); + expect(rain.colorScale.value, RainColorScale.coarse); + + await rain.setColorScale(RainColorScale.fine); + expect( + rain.colorScale.value, + RainColorScale.fine, + reason: 'a deliberate choice is not overridden while it stands', + ); + expect( + rain.interval.value, + RainInterval.hour24, + reason: 'changing the scale must not move the window', + ); + + await rain.setInterval(RainInterval.day2); + expect( + rain.colorScale.value, + RainColorScale.coarse, + reason: + 'the choice was made about a different range, so a new window ' + 'starts from the suggestion again', + ); + }); + + test('a scale change keeps the dot size and its white outline', () async { + final rain = layer(); + final controller = RecordingMapController(); + await rain.render(controller); + + final mounted = controller.lastProperties.keys.where( + (id) => id.endsWith('-circle'), + ); + expect(mounted, isEmpty, reason: 'nothing re-asserted yet'); + + await rain.setColorScale(RainColorScale.coarse); + + final circleId = controller.lastProperties.keys.firstWhere( + (id) => id.endsWith('-circle'), + orElse: () => fail('the ramp was never re-asserted on the dot layer'), + ); + final sent = controller.lastProperties[circleId]!; + // setLayerProperties defaults to skipNulls: false and assigns EVERY + // field, so a colour-only update silently resets the rest. It did: + // the dots shrank to MapLibre's default radius and lost their outline. + expect(sent['circle-radius'], 6, reason: 'dot size must not change'); + expect(sent['circle-stroke-width'], 1, reason: 'the outline must stay'); + expect(sent['circle-stroke-color'], '#FFFFFF'); + expect(sent['circle-opacity'], 0.9); + expect(sent['circle-color'], isNotNull); + }); + + test('re-selecting the current window leaves an override alone', () async { + final rain = layer(); + await rain.setColorScale(RainColorScale.coarse); + await rain.setInterval(RainInterval.hour1); + expect(rain.colorScale.value, RainColorScale.coarse); + }); + }); + + group('stepColor', () { + final stops = RainColorScale.fine.stops; + Color? at(double mm) => stepColor(stops, mm); + + test('a value takes its band floor colour, never a blend', () { + // 70 mm opens the red band; nothing up to (but excluding) 90 changes it. + final red = colorFromHexRgb('#ff0000'); + expect(at(70), red); + expect(at(71), red); + expect(at(89.9), red); + expect( + at(90), + colorFromHexRgb('#c80000'), + reason: 'the next floor takes over exactly at its own value', + ); + }); + + test('below the first boundary is the dry band', () { + expect(at(0), colorFromHexRgb('#c2c2c2')); + expect(at(0.9), colorFromHexRgb('#c2c2c2')); + expect(at(1), colorFromHexRgb('#a0fffa')); + }); + + test('above the last boundary stays the top band', () { + final top = colorFromHexRgb('#ffc8ff'); + expect(at(300), top); + expect(at(9999), top); + }); + + test('only the declared colours are ever produced', () { + final allowed = {for (final (_, hex) in stops) colorFromHexRgb(hex)}; + for (var mm = 0.0; mm <= 400; mm += 0.5) { + expect( + allowed, + contains(at(mm)), + reason: '$mm mm produced a colour that is in no band', + ); + } + }); + + test('an empty ramp has no colour rather than throwing', () { + expect(stepColor(const [], 5), isNull); + }); + }); +} + +/// Minimal stand-in: the layer under test never fetches — these cases exercise +/// the window/scale coupling, which is pure state. +class _StubRainRepository implements MeteorRainRepository { + @override + Future>> stations() async => const Ok({}); + + @override + Future> latest() async => + const Ok(RainSnapshot(time: 0, stations: [])); + + @override + Future>> history() async => const Ok([]); + + @override + Future> at(int second) async => + const Ok(RainSnapshot(time: 0, stations: [])); + + @override + Future> trend(String id, {String range = '24h'}) async => + Ok(RainTrend(id: id, range: range, times: const [], rain: const [])); +} diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index c5f1a7e77..964a792df 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -328,6 +328,7 @@ class RecordingMapController implements MapLibreMapController { final json = properties.toJson(); calls.add('set:$layerId:${json['raster-opacity']}'); sentKeys.add(json.keys.toSet()); + lastProperties[layerId] = json; _record(layerId, properties); } @@ -341,10 +342,19 @@ class RecordingMapController implements MapLibreMapController { final json = update.properties.toJson(skipNulls: skipNulls); calls.add('set:${update.layerId}:${json['raster-opacity']}'); sentKeys.add(json.keys.toSet()); + lastProperties[update.layerId] = json; _record(update.layerId, update.properties); } } + /// The most recent property JSON sent for each layer. + /// + /// Kept raw and un-merged, unlike [_state]: the real `setLayerProperties` + /// defaults to `skipNulls: false` and then *assigns every field*, so a caller + /// that omits one silently resets it. A merging model cannot see that class + /// of bug, so this records exactly what went over the wire. + final Map> lastProperties = {}; + /// Merges into the layer's state: the layer keeps whatever a call omits, /// which is exactly what `skipNulls` means on the wire. void _record(String layerId, LayerProperties properties) { From 635e9bb26bdda10ee65f3116db1d482c673561c3 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 09:35:32 +0800 Subject: [PATCH 11/40] docs(weather): correct the QPESUMS grid extent to cell edges --- api.md | 5 +++-- .../map/presentation/layers/qpesums_scan_range.dart | 11 ++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/api.md b/api.md index e9bb5e010..050370909 100644 --- a/api.md +++ b/api.md @@ -122,8 +122,9 @@ QPESUMS 定量降水預報 XYZ WebP。時間清單是差量編碼的 Unix **毫 (`[baseMs, Δ, …]`);tile 在 **static** 主機。`{ms}` 就是解出清單後的 13 位數 毫秒,直接使用(時間軸解析已同時支援秒與毫秒)。 -**覆蓋範圍是方形,整塊都有資料**:118.0–123.5125°E、20.0–27.0125°N,442 × 562 -格心,步長 0.0125°(與雷達同解析度)。 +**覆蓋範圍是方形,整塊都有資料**:441 × 561 格,每格 0.0125° +(與雷達同解析度);118.0–123.5125°E、20.0–27.0125°N 是整塊格網的外緣, +不是格心座標。 這**不是**雷達的有效範圍。預報發布在自己的網格上,雷達的是測距圓聯集,兩者在 方形四角(預報有、圓弧無)與圓弧外凸處(圓弧有、方形無)都不一致。「顯示掃描 diff --git a/lib/features/map/presentation/layers/qpesums_scan_range.dart b/lib/features/map/presentation/layers/qpesums_scan_range.dart index 83f6911f0..65fa61f19 100644 --- a/lib/features/map/presentation/layers/qpesums_scan_range.dart +++ b/lib/features/map/presentation/layers/qpesums_scan_range.dart @@ -8,10 +8,11 @@ import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; /// outlining it with the radar circles claimed coverage on the corners the /// forecast does have and denied it along the edges of the circles. /// -/// 442 × 562 cell centres at the same 0.0125° step the composite uses: -/// `118.0 + 441 × 0.0125 = 123.5125` and `20.0 + 561 × 0.0125 = 27.0125`. The -/// bounds are written out rather than derived so the numbers in the file are -/// the numbers on the wire. +/// 441 × 561 cells at the same 0.0125° step the composite uses. 118.0°E, +/// 20.0°N is the south-west edge of the first cell; the opposite **outer** +/// edges are `118.0 + 441 × 0.0125 = 123.5125` and +/// `20.0 + 561 × 0.0125 = 27.0125`. The bounds are written out rather than +/// derived so the numbers in the file are the numbers on the wire. abstract final class QpesumsScanRange { QpesumsScanRange._(); @@ -55,7 +56,7 @@ abstract final class QpesumsScanRange { 'type': 'Feature', 'properties': { 'name': 'effective_extent', - 'note': 'QPESUMS forecast grid, 442×562 at 0.0125°', + 'note': 'QPESUMS forecast grid, 441×561 cells at 0.0125°', }, 'geometry': { 'type': 'Polygon', From dd9ba9ba3d3a40c8964286d21d814e01f21fe4e7 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 09:35:34 +0800 Subject: [PATCH 12/40] build(android): keep Hybrid Composition++ off for the map view Platform: android --- android/app/src/main/AndroidManifest.xml | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0d090c186..16e10ab14 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,6 +35,31 @@ android:roundIcon="@mipmap/ic_launcher_round" android:networkSecurityConfig="@xml/network_security_config" android:allowBackup="false"> + + + + Date: Mon, 24 Aug 2026 09:36:19 +0800 Subject: [PATCH 13/40] refactor(storage): let the cache schema drop a legacy table itself --- lib/bootstrap.dart | 8 ++-- lib/core/network/etag_cache_store.dart | 30 +++++++++++++- test/core/network/etag_cache_store_test.dart | 43 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 7d82dac7e..f3949473e 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -418,10 +418,10 @@ Future bootstrap() async { /// than failing to launch. The usage tables are created with `IF NOT EXISTS` on /// every open, so they're added to a pre-existing cache DB without a version bump. /// -/// The v1→v2 migration rides a probe instead of sqflite's `version`/`onUpgrade` -/// hooks (sqlite_async has none): the v2 `CREATE TABLE IF NOT EXISTS` is -/// harmless against either shape — [EtagCacheStore.migrateToV2] is what drops -/// the legacy envelope table when its columns say v1. +/// The v1→v2 migration rides a column probe instead of sqflite's +/// `version`/`onUpgrade` hooks (sqlite_async has none): +/// [EtagCacheStore.createSchema] drops the re-fetchable v1 envelope table when +/// its columns do not match v2, then creates a clean columnar cache. Future<({EtagCacheStore etag, NetworkUsageStore usage, SqliteDatabase db})?> _openCache() async { try { diff --git a/lib/core/network/etag_cache_store.dart b/lib/core/network/etag_cache_store.dart index 388c11773..727359898 100644 --- a/lib/core/network/etag_cache_store.dart +++ b/lib/core/network/etag_cache_store.dart @@ -191,8 +191,34 @@ class EtagCacheStore { int? _trackedBytes; int _writesSinceSweep = 0; - /// Creates the v2 cache table (idempotent) — call from `onCreate` / migrate. + static const _v2Columns = { + 'key', + 'etag', + 'content_type', + 'kind', + 'body', + 'size', + 'time', + }; + + /// Creates the v2 cache table, dropping an incompatible legacy cache first. + /// + /// v1 stored one `value` envelope instead of v2's columnar body. There is no + /// data migration on purpose: every row is re-fetchable, so a clean drop is + /// both safer and cheaper than decoding and rewriting hundreds of megabytes. static Future createSchema(SqliteDatabase db) async { + final columns = { + for (final row in await db.getAll('PRAGMA table_info($_table)')) + if (row['name'] case final String name) name, + }; + if (columns.isNotEmpty && !columns.containsAll(_v2Columns)) { + await migrateToV2(db); + return; + } + await _createV2Schema(db); + } + + static Future _createV2Schema(SqliteDatabase db) async { await db.executeMultiple( 'CREATE TABLE IF NOT EXISTS $_table (' 'key TEXT PRIMARY KEY, ' @@ -229,7 +255,7 @@ class EtagCacheStore { /// every legacy row on the UI isolate. static Future migrateToV2(SqliteDatabase db) async { await db.execute('DROP TABLE IF EXISTS $_table'); - await createSchema(db); + await _createV2Schema(db); } /// Returns the cached **JSON** entry for [url], or null on a miss. diff --git a/test/core/network/etag_cache_store_test.dart b/test/core/network/etag_cache_store_test.dart index 8d888c070..f9d229778 100644 --- a/test/core/network/etag_cache_store_test.dart +++ b/test/core/network/etag_cache_store_test.dart @@ -21,6 +21,49 @@ void main() { tearDown(() async => db.close()); + test('opening v2 drops the legacy envelope table cleanly', () async { + final legacy = openMemoryDb(); + addTearDown(legacy.close); + await legacy.executeMultiple( + 'CREATE TABLE http_cache (' + 'key TEXT PRIMARY KEY, value BLOB NOT NULL, time INTEGER NOT NULL);' + 'CREATE INDEX http_cache_time ON http_cache(time)', + ); + await legacy.execute( + 'INSERT INTO http_cache (key, value, time) VALUES (?, ?, ?)', + [ + 'https://x/v1', + Uint8List.fromList([1, 2, 3]), + 1, + ], + ); + + await EtagCacheStore.createSchema(legacy); + + final columns = { + for (final row in await legacy.getAll('PRAGMA table_info(http_cache)')) + row['name'], + }; + expect(columns, { + 'key', + 'etag', + 'content_type', + 'kind', + 'body', + 'size', + 'time', + }); + expect( + (await legacy.get('SELECT COUNT(*) AS n FROM http_cache'))['n'], + 0, + reason: 'v1 is re-fetchable and must be dropped, not rewritten', + ); + + final migrated = EtagCacheStore(legacy); + await migrated.write('https://x/v2', etag: '2', body: '{"ok":true}'); + expect((await migrated.read('https://x/v2'))?.body, '{"ok":true}'); + }); + Future setTime(String url, int time) => db.execute('UPDATE http_cache SET time = ? WHERE key = ?', [time, url]); From 0af86c40fceaa465e9beef841871f6e037f2f5ca Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 09:36:28 +0800 Subject: [PATCH 14/40] refactor(settings): harden attach reconciliation against races --- lib/bootstrap.dart | 9 +- lib/core/settings/settings_store.dart | 96 +++++++++++++-------- test/core/settings/settings_store_test.dart | 42 +++++++++ 3 files changed, 109 insertions(+), 38 deletions(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index f3949473e..20fa906b9 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -510,9 +510,10 @@ Future _recoverDurable( const interval = Duration(seconds: 3); for (var attempt = 1; attempt <= attempts; attempt++) { await Future.delayed(interval); + SqliteDatabase? db; try { final base = await getApplicationSupportDirectory(); - final db = SqliteDatabase( + db = SqliteDatabase( path: '${base.path}/dpip.db', options: const SqliteOptions(synchronous: SqliteSynchronous.full), ); @@ -529,6 +530,12 @@ Future _recoverDurable( onboardingRefresh.fire(); return; } catch (error) { + try { + await db?.close(); + } on Object { + // The open/attach error is the useful failure. Closing a partial pool + // must not replace it or prevent the next recovery attempt. + } Log.warning('durable recovery attempt $attempt/$attempts failed: $error'); } } diff --git a/lib/core/settings/settings_store.dart b/lib/core/settings/settings_store.dart index 99aaf56b3..1c7cdf80d 100644 --- a/lib/core/settings/settings_store.dart +++ b/lib/core/settings/settings_store.dart @@ -169,52 +169,74 @@ final class SettingsStore { /// spending its whole life looking like a first run. /// /// Returns whether anything moved in either direction. Attaching to an - /// already-attached store is a no-op that answers false. + /// already-attached store is a no-op that answers false. A reconciliation + /// failure leaves the store degraded and throws, so the caller can close the + /// failed handle and retry without losing the backlog. Future attachDatabase(SqliteDatabase db) async { if (_db != null) return false; var moved = false; - // Replay first: a session write over the same key must win over the - // stale row the database still holds from before the degradation. - final pending = Map.of(_pendingWrites); - _pendingWrites.clear(); - try { - for (final MapEntry(key: name, :value) in pending.entries) { - if (value == null) { - await db.execute('DELETE FROM $settingsTable WHERE key = ?', [name]); - } else { - await db.execute( - 'INSERT OR REPLACE INTO $settingsTable (key, value) VALUES (?, ?)', - [name, jsonEncode(value)], - ); - } + // Adopt disk first, without overwriting anything this session has already + // read or written. A write racing this await updates [_values] immediately, + // so the containsKey check still gives the session the final say. + for (final row in await db.getAll( + 'SELECT key, value FROM $settingsTable', + )) { + final name = row['key'] as String?; + final raw = row['value'] as String?; + if (name == null || + raw == null || + _values.containsKey(name) || + _pendingWrites.containsKey(name)) { + continue; + } + try { + _values[name] = jsonDecode(raw); moved = true; + } catch (_) { + // A row unreadable at attach time is no better than one unreadable at + // load time — skip it rather than poison the session. } - for (final row in await db.getAll( - 'SELECT key, value FROM $settingsTable', - )) { - final name = row['key'] as String?; - final raw = row['value'] as String?; - if (name == null || raw == null || _values.containsKey(name)) continue; - try { - _values[name] = jsonDecode(raw); - moved = true; - } catch (_) { - // A row unreadable at attach time is no better than one unreadable - // at load time — skip it rather than poison the session. - } + } + + // Drain until empty. Writes keep using [_pendingWrites] while [_db] is + // null; checking empty and publishing [_db] contain no await between them, + // so no write can land in an orphaned queue at the hand-off boundary. + while (true) { + final pending = Map.of(_pendingWrites); + if (pending.isEmpty) { + _db = db; + return moved; } - } catch (error, stackTrace) { - // Whatever failed stays pending for another attempt; the memory copy is - // already authoritative either way. - for (final entry in pending.entries) { - if (!_pendingWrites.containsKey(entry.key)) { - _pendingWrites[entry.key] = entry.value; + _pendingWrites.clear(); + try { + await db.writeTransaction((tx) async { + for (final MapEntry(key: name, :value) in pending.entries) { + if (value == null) { + await tx.execute('DELETE FROM $settingsTable WHERE key = ?', [ + name, + ]); + } else { + await tx.execute( + 'INSERT OR REPLACE INTO $settingsTable (key, value) ' + 'VALUES (?, ?)', + [name, jsonEncode(value)], + ); + } + } + }); + moved = true; + } catch (error, stackTrace) { + // A newer racing write for the same key wins; otherwise restore the + // failed batch intact for the next recovery attempt. + for (final entry in pending.entries) { + if (!_pendingWrites.containsKey(entry.key)) { + _pendingWrites[entry.key] = entry.value; + } } + Log.handle(error, stackTrace, 'attaching durable settings database'); + rethrow; } - Log.handle(error, stackTrace, 'attaching durable settings database'); } - _db = db; - return moved; } Future _put(SettingKey key, Object value) async { diff --git a/test/core/settings/settings_store_test.dart b/test/core/settings/settings_store_test.dart index 523409ea2..c332914a6 100644 --- a/test/core/settings/settings_store_test.dart +++ b/test/core/settings/settings_store_test.dart @@ -104,6 +104,7 @@ void main() { // session read or wrote. final db = await _db(); await insertRow(db, SettingKeys.onboardingComplete.name, 'true'); + await insertRow(db, SettingKeys.experimentalUnlocked.name, 'true'); final store = await SettingsStore.open(null); expect(store.isDegraded, isTrue); @@ -120,6 +121,12 @@ void main() { (await SettingsStore.open(db)).getInt(SettingKeys.channelVersion), 7, ); + expect(store.getBool(SettingKeys.experimentalUnlocked), isNull); + expect( + (await SettingsStore.open(db)).getBool(SettingKeys.experimentalUnlocked), + isNull, + reason: 'a degraded-session removal must not be adopted back from disk', + ); // A second attach is a no-op. expect(await store.attachDatabase(db), isFalse); @@ -140,6 +147,41 @@ void main() { expect((await SettingsStore.open(db)).getString(SettingKeys.locale), 'th'); }); + test('a write racing attach is drained before the hand-off', () async { + final db = await _db(); + final store = await SettingsStore.open(null); + + final attaching = store.attachDatabase(db); + // attach has yielded to its first database read while the store is still + // degraded. This write must join the backlog, then be drained before _db + // becomes visible to later writes. + await store.setString(SettingKeys.locale, 'th'); + + expect(await attaching, isTrue); + expect(store.isDegraded, isFalse); + expect((await SettingsStore.open(db)).getString(SettingKeys.locale), 'th'); + }); + + test('a failed attach stays degraded and preserves its backlog', () async { + final failed = await _db(); + await failed.execute( + 'CREATE TRIGGER reject_setting BEFORE INSERT ON $settingsTable ' + "BEGIN SELECT RAISE(ABORT, 'blocked'); END", + ); + final store = await SettingsStore.open(null); + await store.setString(SettingKeys.locale, 'ja'); + + await expectLater(store.attachDatabase(failed), throwsA(anything)); + expect(store.isDegraded, isTrue); + + await failed.execute('DROP TRIGGER reject_setting'); + expect(await store.attachDatabase(failed), isTrue); + expect( + (await SettingsStore.open(failed)).getString(SettingKeys.locale), + 'ja', + ); + }); + test('a value written under one key is invisible under another', () { // The registry is the whole persisted surface; two keys sharing a storage // address would make one setting silently overwrite another. From ca785bcc9a1612e78728e90d3a0293b9a4d9fe48 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 09:38:44 +0800 Subject: [PATCH 15/40] perf(map): stop the timeline warm from thrashing its own caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 地圖時間軸的預熱不再互相沖掉剛載入的圖磚,拖曳時間軸與平移地圖後更快穩定、不再閃空白 Optimization(en-US): the map timeline's tile warming no longer evicts tiles it just loaded, so scrubs and pans settle faster without flashing blank --- lib/shared/map/map_tile_cache.dart | 14 +++ lib/shared/map/map_tile_warmer.dart | 37 ++++--- lib/shared/map/raster_timeline_layer.dart | 83 ++++++++++++--- test/features/map/radar_layer_test.dart | 9 +- test/shared/map/map_tile_cache_test.dart | 124 ++++++++++++++-------- 5 files changed, 193 insertions(+), 74 deletions(-) diff --git a/lib/shared/map/map_tile_cache.dart b/lib/shared/map/map_tile_cache.dart index 29dea8bea..fe34be79f 100644 --- a/lib/shared/map/map_tile_cache.dart +++ b/lib/shared/map/map_tile_cache.dart @@ -284,6 +284,14 @@ class MapTileCache { if (_isTile(url)) url, }.toList(growable: false); if (wanted.isEmpty) return (injected: 0, resident: {}); + if (shouldContinue?.call() == false) { + // Checked before the probe, not only inside it. A superseded schedule + // used to spend its full probe first — a device trace caught one paying + // 548 ms over 5,632 URLs and then reporting `cancelled before-l2`, + // having done nothing but delay the fill that replaced it. + trace(() => 'warm cancelled before-probe wanted=${wanted.length}'); + return (injected: 0, resident: {}); + } final traceId = ++_traceSequence; final elapsed = Stopwatch()..start(); trace( @@ -429,6 +437,12 @@ class MapTileCache { var messages = 0; for (var i = 0; i < wanted.length; i += _probeChunk) { final end = math.min(i + _probeChunk, wanted.length); + if (shouldContinue?.call() == false) { + // Before the message, so a cancellation that lands mid-sweep costs the + // chunk in flight rather than the chunk after it as well. + missing.addAll(wanted.sublist(i)); + break; + } missing.addAll(await mapLibreTilesMissing(wanted.sublist(i, end))); if (shouldContinue?.call() == false) { // Unprobed URLs are conservatively unknown/missing. That keeps the diff --git a/lib/shared/map/map_tile_warmer.dart b/lib/shared/map/map_tile_warmer.dart index 015df4837..b1e66a452 100644 --- a/lib/shared/map/map_tile_warmer.dart +++ b/lib/shared/map/map_tile_warmer.dart @@ -273,14 +273,15 @@ class MapTileWarmer { final direct = {}; final framesByFamily = >{}; - final flushFamilies = {}; for (final url in stale) { final prefix = _framePrefix(url); final family = _frameFamilyPrefix(url); if (prefix == null || family == null) { direct.add(url); } else if (wantedFrames.contains(prefix)) { - flushFamilies.add(family); + // Stale coordinates inside a frame the new set still wants: the camera + // moved, not the frame. Left alone — see below. + continue; } else { (framesByFamily[family] ??= {}).add(prefix); } @@ -290,20 +291,30 @@ class MapTileWarmer { for (final entry in framesByFamily.entries) { final family = entry.key; final frames = entry.value; - if (flushFamilies.contains(family) || - frames.length > _maxFrameEvictionPatterns) { + final familyStillWanted = wantedFrames.any( + (wanted) => wanted.startsWith(family), + ); + if (!familyStillWanted) { + // Nothing of this family survives the change — one prefix is both the + // cheapest needle and an exact one. patterns.add(family); - } else { + } else if (frames.length <= _maxFrameEvictionPatterns) { patterns.addAll(frames); } - } - // A retained frame can be the only stale member of its family, so it may - // not have created a framesByFamily entry above. - patterns.addAll(flushFamilies); - for (final family in flushFamilies) { - patterns.removeWhere( - (pattern) => pattern != family && pattern.startsWith(family), - ); + // Otherwise: too many frames to name individually, and the family is + // still live. Evict nothing. + // + // A family prefix matches *every* tile of that overlay, so collapsing to + // it while the family is still wanted does not trim the working set — it + // wipes it. A device trace caught this costing a full refill on every + // camera nudge: 1,159 resident tiles, 737 of them stale, collapsed to + // `/api/v2/tiles/radar/`, and the next probe reported `l1-hit=0` and read + // megabytes back out of SQLite that had been in memory a moment earlier. + // + // Over-retention is bounded and self-correcting: the mirror is a byte- + // capped LRU that trims its own least-recently-used entries. Wiping live + // tiles is neither. When the choice is between holding stale bytes the + // LRU will reclaim and re-reading live ones from disk, hold them. } return patterns.toList(growable: false); } diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index 007535ca9..54e6aa06b 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -94,14 +94,22 @@ abstract class RasterTimelineLayer implements MapLayer { /// Maximum frame candidates considered by one settled fill. /// - /// 512 candidates are intentionally wider than the 48 MiB mirror can usually - /// hold. The fill reads them centre-out in bounded batches and stops at 90% - /// of the real native cap, so this maximises the useful L1 range without - /// loading every candidate body into Dart or allowing a slow device to turn - /// one settle into an unbounded whole-history scan. Near a series edge the - /// unused side is given to the other side instead of wasting half the budget. + /// This was 512, chosen to be wider than the 48 MiB mirror can hold on the + /// reasoning that the fill stops at 90% of the native cap anyway. A device + /// trace showed why that reasoning does not survive contact: 512 candidates + /// is 12,288 tile URLs, whose **L1 presence probe alone cost 635 ms** — 32 + /// platform messages — before a single byte was read, and the fill that + /// followed took 2.7 s and was superseded after scanning 3,456 of them. The + /// budget was never the mirror; it was the probe, and it was being paid in + /// full on every camera idle for a fill that rarely finished. + /// + /// 128 keeps a band several times wider than a fast drag can cross, costs a + /// quarter of the probe, and — being ~8 MiB of bodies — completes well inside + /// the mirror instead of racing it. A band that finishes is worth more than a + /// wider one that is cancelled. Near a series edge the unused side is given + /// to the other side instead of wasting half the budget. @protected - int get warmFrameBudget => 512; + int get warmFrameBudget => 128; /// Mounted-source ceiling. This is deliberately much smaller than /// [warmFrameBudget]: L1 holds compressed response bodies, while a mounted @@ -314,7 +322,7 @@ abstract class RasterTimelineLayer implements MapLayer { _refreshResidentOnNextSettle |= hadFrame; _surfaceVisible = false; _resumeBackgroundWork = false; - _warmCentre = null; + _invalidateWarmBand(); _revealGeneration++; source.cancelTileWarm(); _mapController = null; @@ -413,6 +421,41 @@ abstract class RasterTimelineLayer implements MapLayer { /// re-warm instead of one per frame. int? _warmCentre; + /// The camera [_warmCentre] was warmed for. + /// + /// The band is a function of both the frame it centres on **and** the + /// rectangle it warms, so the skip-guard has to be keyed on both. It used to + /// be keyed on the centre alone, which meant a camera move — same centre, new + /// viewport — could not be told apart from a duplicate call. [onCameraIdle] + /// worked around that by nulling the centre, which defeated the guard + /// outright: every idle re-ran the whole fill for a centre it had just + /// finished, evicting the tiles it had spent seconds injecting. A device + /// trace showed three 512-frame band warms inside four seconds, the second + /// evicting 1,358 freshly injected tiles and the third being cancelled + /// mid-probe. + String? _warmCamera; + + /// The camera, rounded to the precision the warm actually depends on. + /// + /// Comparing [CameraPosition] directly makes the guard useless: the camera + /// settles with sub-pixel jitter, so every idle reported a different position + /// and re-ran the band. The band depends on which tiles the viewport covers, + /// and that does not change for a ten-thousandth of a degree — roughly 10 m, + /// against a tile that is kilometres across at these zooms. + static String? _warmKeyFor(CameraPosition? camera) { + if (camera == null) return null; + return '${camera.target.latitude.toStringAsFixed(4)},' + '${camera.target.longitude.toStringAsFixed(4)},' + '${camera.zoom.toStringAsFixed(2)},' + '${camera.bearing.round()},${camera.tilt.round()}'; + } + + /// Forgets the last warmed band so the next call re-warms. + void _invalidateWarmBand() { + _warmCentre = null; + _warmCamera = null; + } + String _sourceId(String frameId) => '$id-src-$frameId'; String _layerId(String frameId) => '$id-lyr-$frameId'; @@ -509,7 +552,7 @@ abstract class RasterTimelineLayer implements MapLayer { // timestamp. Both cancelled the old fill, so restart the wide L1 // fill and ready-resident preload instead of leaving only the core // ring available to the next scrub. - _warmCentre = null; + _invalidateWarmBand(); unawaited( _warmThenPreload( controller, @@ -1200,7 +1243,7 @@ abstract class RasterTimelineLayer implements MapLayer { void _suspendWarm() { if (_warmSuspended) return; _warmSuspended = true; - _warmCentre = null; + _invalidateWarmBand(); source.cancelTileWarm(); MapTileCache.trace(() => 'timeline=$id warm-suspend'); } @@ -1424,8 +1467,11 @@ abstract class RasterTimelineLayer implements MapLayer { // that cross the old band edge coalesce onto this one re-warm instead of // each firing its own visible-region round-trip. final previous = _warmCentre; - if (previous == centre) return; + final previousCamera = _warmCamera; + final camera = _warmKeyFor(controller.cameraPosition); + if (previous == centre && previousCamera == camera) return; _warmCentre = centre; + _warmCamera = camera; final delta = previous == null ? 1 : centre - previous; final frames = _spreadFrames(centre, direction: delta < 0 ? -1 : 1); if (frames.length <= 1) return; @@ -1433,6 +1479,7 @@ abstract class RasterTimelineLayer implements MapLayer { MapTileCache.trace( () => 'timeline=$id warm-band start centre=$centre previous=$previous ' + 'camera=$camera was=${previousCamera == camera ? 'same' : previousCamera} ' 'direction=${delta < 0 ? 'backward' : 'forward'} ' 'frames=${frames.length} immediate=$immediate ' 'refresh=$refreshResident', @@ -1441,7 +1488,9 @@ abstract class RasterTimelineLayer implements MapLayer { final viewport = await _viewport(controller); // Visibility can change while the platform answers the camera query. Do // not start a fresh warmer generation after the hidden-edge cancellation. - if (!_surfaceVisible || _warmCentre != centre) return; + if (!_surfaceVisible || _warmCentre != centre || _warmCamera != camera) { + return; + } await source.warmFrameTiles( frames: frames, south: viewport.bounds.southwest.latitude, @@ -1554,9 +1603,9 @@ abstract class RasterTimelineLayer implements MapLayer { if (_warmSuspended) return; final centre = _shownIndex; if (centre == null) return; - _warmCentre = null; - // The viewport moved, so the warmed tiles are the wrong ones — re-warm for - // where the camera actually is. + // No invalidation here. The band is keyed on the camera as well as the + // centre, so a real move re-warms on its own and an idle that reports the + // same camera is the duplicate it looks like. MapTileCache.trace(() => 'timeline=$id camera-idle centre=$centre'); unawaited(_warmBand(controller, centre, immediate: true)); } @@ -1566,7 +1615,7 @@ abstract class RasterTimelineLayer implements MapLayer { if (!_surfaceVisible) return; final centre = _shownIndex; if (centre == null) return; - _warmCentre = null; + _invalidateWarmBand(); await _warmBand(controller, centre, immediate: true); } @@ -1623,7 +1672,7 @@ abstract class RasterTimelineLayer implements MapLayer { _requestedFrameId = null; _shownFrameId = null; _settledFrameId = null; - _warmCentre = null; + _invalidateWarmBand(); _attached = false; // A style reload drops every runtime layer, the seam included. _seamMounted = false; diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 26c776185..6433a91ba 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -904,12 +904,17 @@ void main() { await layer.show(controller, frames.last); await pumpEventQueue(); + // What this pins is the edge reassignment, not the number: at the newest + // frame there is no future side, so the whole budget goes to older frames. + // `RasterTimelineLayer.warmFrameBudget` is the source of truth for the + // number itself, and it is deliberately not 512 any more — see its doc. + const budget = 128; final warmed = source.warmed.single; - expect(warmed, hasLength(512)); + expect(warmed, hasLength(budget)); expect(warmed.first, frames.last.id); expect( warmed.last, - frames[frames.length - 512].id, + frames[frames.length - budget].id, reason: 'the missing future side is reassigned to older cached frames', ); }); diff --git a/test/shared/map/map_tile_cache_test.dart b/test/shared/map/map_tile_cache_test.dart index f0977eb60..18829e51c 100644 --- a/test/shared/map/map_tile_cache_test.dart +++ b/test/shared/map/map_tile_cache_test.dart @@ -710,50 +710,54 @@ void main() { expect(nativeMemory.keys, unorderedEquals([keep, fresh])); }); - test( - 'a viewport change evicts one raster family, never every stale URL', - () async { - await cache.install(memoryBytes: 1024); - final warmer = MapTileWarmer(cache, settleDelay: Duration.zero); - const retiredFrame = - 'https://static.exptech.dev/api/v2/tiles/radar/1787236800'; - const retainedFrame = - 'https://static.exptech.dev/api/v2/tiles/radar/1787237400'; - const retiredA = '$retiredFrame/7/106/55.webp?style=jma'; - const retiredB = '$retiredFrame/7/107/55.webp?style=jma'; - const staleInRetained = '$retainedFrame/7/106/55.webp?style=jma'; - const keep = '$retainedFrame/7/107/55.webp?style=jma'; - for (final (url, value) in [ - (retiredA, 1), - (retiredB, 2), - (staleInRetained, 3), - (keep, 4), - ]) { - await store.writeBytes( - url, - etag: '$value', - bytes: Uint8List.fromList([value]), - ); - } - await warmer.warmUrls([ - retiredA, - retiredB, - staleInRetained, - keep, - ], immediate: true); - nativeCalls.clear(); + test('a viewport change evicts one raster family, never every stale URL', () async { + await cache.install(memoryBytes: 1024); + final warmer = MapTileWarmer(cache, settleDelay: Duration.zero); + const retiredFrame = + 'https://static.exptech.dev/api/v2/tiles/radar/1787236800'; + const retainedFrame = + 'https://static.exptech.dev/api/v2/tiles/radar/1787237400'; + const retiredA = '$retiredFrame/7/106/55.webp?style=jma'; + const retiredB = '$retiredFrame/7/107/55.webp?style=jma'; + const staleInRetained = '$retainedFrame/7/106/55.webp?style=jma'; + const keep = '$retainedFrame/7/107/55.webp?style=jma'; + for (final (url, value) in [ + (retiredA, 1), + (retiredB, 2), + (staleInRetained, 3), + (keep, 4), + ]) { + await store.writeBytes( + url, + etag: '$value', + bytes: Uint8List.fromList([value]), + ); + } + await warmer.warmUrls([ + retiredA, + retiredB, + staleInRetained, + keep, + ], immediate: true); + nativeCalls.clear(); - await warmer.warmUrls([keep], immediate: true); + await warmer.warmUrls([keep], immediate: true); - final evict = nativeCalls.firstWhere((c) => c.method == 'evictTiles'); - expect((evict.arguments as Map)['contains'], [ - 'https://static.exptech.dev/api/v2/tiles/radar/', - ]); - expect(nativeMemory.keys, [keep]); - }, - ); + final evict = nativeCalls.firstWhere((c) => c.method == 'evictTiles'); + expect((evict.arguments as Map)['contains'], [ + '$retiredFrame/', + ], reason: 'the retired frame is named precisely, not by its family'); + expect( + nativeMemory.keys, + unorderedEquals([staleInRetained, keep]), + reason: + 'a coordinate the camera moved off is still a live frame. Dropping ' + 'it means re-reading it from disk the moment the camera moves back, ' + 'and the mirror is a byte-capped LRU that reclaims it for free.', + ); + }); - test('many retired raster frames collapse to one family eviction', () async { + test('a live family is never wiped to reclaim its retired frames', () async { await cache.install(memoryBytes: 1024); final warmer = MapTileWarmer(cache, settleDelay: Duration.zero); final old = [ @@ -772,11 +776,47 @@ void main() { await warmer.warmUrls([fresh], immediate: true); + // Twelve retired frames is more than can be named individually, and the + // family prefix matches every radar tile there is — including the one just + // asked for. Naming it would not trim the working set, it would wipe it: a + // device trace caught exactly this costing a full SQLite refill on every + // camera nudge. Holding stale bytes the mirror's LRU will reclaim is the + // cheaper mistake. + expect( + nativeCalls.where((call) => call.method == 'evictTiles'), + isEmpty, + reason: 'no needle is better than one that matches the live frame too', + ); + expect(nativeMemory.keys, contains(fresh)); + expect(nativeMemory.keys, hasLength(old.length + 1)); + }); + + test('a wholly retired family still collapses to one eviction', () async { + await cache.install(memoryBytes: 1024); + final warmer = MapTileWarmer(cache, settleDelay: Duration.zero); + final radar = [ + for (var i = 0; i < 12; i++) + 'https://static.exptech.dev/api/v2/tiles/radar/' + '${1700000000 + i * 600}/7/106/55.webp', + ]; + const satellite = + 'https://static.exptech.dev/api/v2/tiles/satellite/' + '1800000000/7/106/55.webp'; + for (final url in [...radar, satellite]) { + await store.writeBytes(url, etag: url, bytes: Uint8List.fromList([1])); + } + await warmer.warmUrls(radar, immediate: true); + nativeCalls.clear(); + + await warmer.warmUrls([satellite], immediate: true); + + // Nothing of the radar family survives the switch, so one prefix is both + // the cheapest needle and an exact one — the case the collapse exists for. final evict = nativeCalls.firstWhere((call) => call.method == 'evictTiles'); expect((evict.arguments as Map)['contains'], [ 'https://static.exptech.dev/api/v2/tiles/radar/', ]); - expect(nativeMemory.keys, [fresh]); + expect(nativeMemory.keys, [satellite]); }); test('named working sets do not evict each other', () async { From 113031100d49f0244e296bb9af3f30711d08de23 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 09:39:19 +0800 Subject: [PATCH 16/40] fix(map): answer the OS memory-pressure call before lmkd picks us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 系統記憶體吃緊時主動釋放隱藏來源的圖磚,Android 不再於約 1 GB 時被系統直接殺掉 Fix(en-US): decoded tile textures are handed back when the OS asks for memory, instead of the process being killed around 1 GB on Android --- lib/shared/map/map_layer.dart | 15 ++++ lib/shared/map/map_scaffold.dart | 28 ++++++ lib/shared/map/raster_timeline_layer.dart | 102 ++++++++++++++++------ test/features/map/radar_layer_test.dart | 94 ++++++++++++++++++++ 4 files changed, 210 insertions(+), 29 deletions(-) diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index 85af41bfa..d9d9aa550 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -212,6 +212,18 @@ abstract interface class MapLayer { /// nobody can see) and push one catch-up on the visible edge. void onSurfaceVisibility(bool visible); + /// Android/iOS asked every process to give memory back. + /// + /// This is not a hint. `TRIM_MEMORY_RUNNING_CRITICAL` is the last notice + /// before lmkd picks a victim, and a process that returns nothing is the + /// process it picks — DPIP was OOM-killed at ~1 GB resident with 341 MB of + /// it in mounted raster textures that no code path was willing to drop. + /// + /// Give back caches only. A layer must still be able to draw what the user + /// is looking at when this returns: never release the displayed frame, and + /// never let a release make a stale feed look current. + Future onMemoryPressure(MapLibreMapController controller); + /// This layer's frames in **chronological order** (oldest first); the last is /// "now". `Ok()` when the layer currently has nothing to show. Future>> frames(); @@ -324,6 +336,9 @@ mixin MapLayerDefaults implements MapLayer { @override void onSurfaceVisibility(bool visible) {} + @override + Future onMemoryPressure(MapLibreMapController controller) async {} + @override double get mapMinZoom => BaseMap.defaultMinZoom; diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 4344e9ce0..81a06e20f 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -415,6 +415,34 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { // a render resume nor a timeline refetch. } + /// The OS is short on memory and asked for caches back. + /// + /// Android delivers this from `onTrimMemory` at `TRIM_MEMORY_RUNNING_LOW` + /// and above; `RUNNING_CRITICAL` is the last notice before lmkd chooses a + /// victim. Returning nothing is how a process becomes that victim — DPIP was + /// OOM-killed at ~1 GB resident, 341 MB of it decoded raster textures held + /// by mounted timeline sources, with no code path willing to drop any of it. + /// + /// Only the active layer is asked: an inactive one holds no mounted sources + /// (`_onLayerSelected` clears it on the way out). The image cache is Flutter's + /// own and is rebuilt on demand. + @override + void didHaveMemoryPressure() { + final controller = _controller; + _trace( + () => + 'memory-pressure active=${_active.id} ' + 'controller=${controller != null}', + ); + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + if (controller == null) return; + _queue( + () => _active.onMemoryPressure(controller), + label: '${_active.id}.memory-pressure', + ); + } + /// A framing request arrived (map re-opened from Home / the nav bar) — apply it /// once the style is up. Leaves it pending if not, for [_onStyleLoaded]. void _onHandoff() { diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index 54e6aa06b..da176182b 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -1210,35 +1210,79 @@ abstract class RasterTimelineLayer implements MapLayer { /// displayed timestamp. Only the extra ready-resident sources are removed; /// their compressed tile bodies remain in the native L1 and SQLite L2 caches /// and are rebuilt by the idle pool after the surface is visible again. - Future _trimHiddenResidents(MapLibreMapController controller) => - _enqueueMutation(() async { - if (_surfaceVisible || _resident.isEmpty) return; - final shown = _shownIndex; - final keep = shown == null - ? {?_shownFrameId} - : _ringAt(shown).$3; - final stale = [ - for (final candidate in _resident) - if (!keep.contains(candidate)) candidate, - ]; - if (stale.isEmpty) return; - mapTrace( - 'timeline/$id', - () => 'hidden-trim start remove=${stale.length} keep=${keep.length}', - ); - await source.abandonFrames(stale); - for (final candidate in stale) { - _resident.remove(candidate); - _ring.remove(candidate); - _readyFrames.remove(candidate); - _lru.remove(candidate); - await _removeFrame(controller, candidate); - } - mapTrace( - 'timeline/$id', - () => 'hidden-trim done resident=${_resident.length}', - ); - }); + Future _trimHiddenResidents(MapLibreMapController controller) async { + if (_surfaceVisible) return; + await _dropResidentsOutsideRing(controller, reason: 'hidden'); + } + + /// Removes every resident source outside the visible ring, outright. + /// + /// `visibility: none` does not release a source's decoded tile textures — the + /// source has to go. This is the only path that actually gives GPU memory + /// back, which is why both the hidden-tab edge and [onMemoryPressure] use it. + /// + /// [keep] defaults to the ring around the shown frame. The shown frame is + /// always retained: this releases speculation, never what the user is + /// looking at. + Future _dropResidentsOutsideRing( + MapLibreMapController controller, { + required String reason, + Set? keep, + }) => _enqueueMutation(() async { + if (_resident.isEmpty) return; + final shown = _shownIndex; + final retain = + keep ?? (shown == null ? {?_shownFrameId} : _ringAt(shown).$3); + final stale = [ + for (final candidate in _resident) + if (!retain.contains(candidate)) candidate, + ]; + if (stale.isEmpty) return; + mapTrace( + 'timeline/$id', + () => '$reason-trim start remove=${stale.length} keep=${retain.length}', + ); + await source.abandonFrames(stale); + for (final candidate in stale) { + _resident.remove(candidate); + _ring.remove(candidate); + _readyFrames.remove(candidate); + _lru.remove(candidate); + await _removeFrame(controller, candidate); + } + mapTrace( + 'timeline/$id', + () => '$reason-trim done resident=${_resident.length}', + ); + }); + + /// Gives memory back to the OS without blanking the map. + /// + /// Three things are released, cheapest-to-rebuild first: the speculative warm + /// band (bytes we merely expected to want), then every mounted source outside + /// the visible ring (decoded textures — the expensive part), and nothing + /// else. The displayed frame and its immediate neighbours stay mounted, so + /// this is invisible to the user beyond a slower scrub afterwards. + /// + /// Deliberately not gated on [_surfaceVisible]: a foreground map is exactly + /// the case that gets a process killed, and it is the case + /// [_trimHiddenResidents] cannot cover. + @override + Future onMemoryPressure(MapLibreMapController controller) async { + // Deliberately not [_suspendWarm]: that flag is sticky until a settle + // clears it, and pressure is a moment, not a mode. Cancel the fill that is + // running and invalidate the band so the next camera idle recomputes it — + // then normal warming resumes on its own. + _revealGeneration++; + _invalidateWarmBand(); + source.cancelTileWarm(); + MapTileCache.trace( + () => + 'timeline=$id memory-pressure resident=${_resident.length} ' + 'ring=${_ring.length} shown=$_shownFrameId', + ); + await _dropResidentsOutsideRing(controller, reason: 'pressure'); + } void _suspendWarm() { if (_warmSuspended) return; diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 6433a91ba..b1dfe811b 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -755,6 +755,100 @@ void main() { ); }); + test( + 'memory pressure releases speculative sources while the map is visible', + () async { + final source = _FakeRadarRepository(_ids(40)); + final layer = RadarMapLayer(source); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[20]); + await pumpEventQueue(); + + int mounted() => + controller.calls + .where((call) => call.startsWith('addSource:radar-src-')) + .length - + controller.calls + .where((call) => call.startsWith('removeSource:radar-src-')) + .length; + + expect( + mounted(), + greaterThan(5), + reason: 'the idle pool preloads beyond the visible ring', + ); + + await layer.onMemoryPressure(controller); + await pumpEventQueue(); + + expect( + mounted(), + 5, + reason: + 'the OS asked for memory back and the surface is visible, so the ' + 'hidden-tab trim cannot cover this: only the visible ring may ' + 'keep its decoded textures', + ); + expect( + source.abandoned, + isNot(contains(frames[20].id)), + reason: 'never release the frame the user is looking at', + ); + }, + ); + + test( + 'a map that is still warming can be trimmed without losing its frame', + () async { + final source = _BlockingWarmRadarRepository(_ids(12)); + final layer = RadarMapLayer(source); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[4]); + await pumpEventQueue(); + expect(source.warmed, hasLength(1)); + + await layer.onMemoryPressure(controller); + await pumpEventQueue(); + + expect( + source.warmCancels, + 1, + reason: 'the speculative fill is the cheapest thing to give back', + ); + + source.warmGate.complete(); + await pumpEventQueue(); + await layer.show(controller, frames[4]); + await pumpEventQueue(); + + expect( + source.warmed, + hasLength(1), + reason: + 'refilling the band the instant the OS asked for memory back is ' + 'the one thing that must not happen — re-showing the frame that ' + 'is already settled is not a reason to warm', + ); + + await layer.show(controller, frames[6]); + await pumpEventQueue(); + + expect( + source.warmed.length, + greaterThan(1), + reason: + 'pressure is a moment, not a mode: the next real settle warms ' + 'normally, so a later scrub is not left permanently cold', + ); + }, + ); + test('a long cached scrub keeps the resident source set bounded', () async { final source = _FakeRadarRepository(_ids(40)); final layer = RadarMapLayer(source); From 81472eb94a8660aeefcd09925a6d48570a2fbd39 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 11:15:43 +0800 Subject: [PATCH 17/40] fix(notify): accept the legacy nested payload the backend still sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 修正推播全部變成公告音效——相容後端仍在送的巢狀格式,通知回到正確類別、音效與點擊頁面 New(en-US): push notifications land on their proper category, sound and tap route again by accepting the legacy nested payload format the backend still sends --- .../notifications/notification_service.dart | 54 +++++++++- .../content_from_message_test.dart | 102 ++++++++++++++++++ 2 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 test/core/notifications/content_from_message_test.dart diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 3994d6e3a..15bc266d6 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:awesome_notifications/awesome_notifications.dart'; @@ -400,26 +401,69 @@ class NotificationService { /// Builds notification content from a message's `data` (preferred, legacy /// format) falling back to its `notification` block, or null when there's /// nothing to show. +/// +/// Two payload shapes arrive here: +/// +/// - **Flat** — `data['channel']` / `data['title']` / `data['body']` / +/// `data['id']`, the contract [ARCHITECTURE.md] describes. +/// - **Nested** — everything packed into `data['content']` as one JSON string +/// (`{channelKey, id, body, …}`), which is what the push producer still +/// sends: the FCM `notification` block carries the visible text while the +/// structured fields ride inside that string. Without reading it back, +/// every such message loses its channel and collapses onto the announcement +/// fallback — wrong sound, wrong tap routing. +/// +/// Flat keys win where both exist. Nested JSON is parsed leniently: malformed +/// or non-object content is treated as absent, never thrown on. NotificationContent? contentFromMessage(RemoteMessage message) { final data = message.data; final notification = message.notification; - final title = (data['title'] as String?) ?? notification?.title; - final body = (data['body'] as String?) ?? notification?.body; + final nested = _nestedContent(data); + String? nestedField(String name) => switch (nested?[name]) { + final String value => value, + final int value => value.toString(), + _ => null, + }; + final title = + (data['title'] as String?) ?? notification?.title ?? nestedField('title'); + final body = + (data['body'] as String?) ?? notification?.body ?? nestedField('body'); if (title == null && body == null) return null; - final channelKey = (data['channel'] as String?) ?? _fallbackChannelKey; + final channelKey = + (data['channel'] as String?) ?? + nestedField('channelKey') ?? + _fallbackChannelKey; + final id = + int.tryParse((data['id'] as String?) ?? '') ?? + int.tryParse(nestedField('id') ?? '') ?? + 0; + final idText = (data['id'] as String?) ?? nestedField('id'); return NotificationContent( - id: int.tryParse((data['id'] as String?) ?? '') ?? 0, + id: id, channelKey: channelKey, title: title, body: body, // Carry channel + id on the payload so an awesome-displayed tap deep-links // symmetrically with the FCM-delivered path. - payload: {'channel': channelKey, 'id': data['id'] as String?}, + payload: {'channel': channelKey, 'id': idText}, wakeUpScreen: true, category: NotificationCategory.Alarm, ); } +/// The structured fields of a message whose producer nested them inside +/// `data['content']` as one JSON string — see [contentFromMessage]'s doc. +Map? _nestedContent(Map data) { + final raw = data['content']; + if (raw is! String || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : null; + } on FormatException { + return null; + } +} + /// Displays a background/terminated **data-only** message via awesome (a /// `notification`-payload message is shown by the OS itself). Runs on a /// background isolate, so awesome must be initialized here before use. diff --git a/test/core/notifications/content_from_message_test.dart b/test/core/notifications/content_from_message_test.dart new file mode 100644 index 000000000..3b8139065 --- /dev/null +++ b/test/core/notifications/content_from_message_test.dart @@ -0,0 +1,102 @@ +/// The push payload → notification content contract. +/// +/// Two shapes arrive over FCM and both must land on the right channel: the +/// flat keys the architecture describes, and the nested `data['content']` +/// JSON string the producer still sends. Getting this wrong does not fail +/// loudly — every message silently collapses onto the announcement fallback, +/// which is exactly how a wrong-sound report presents. +library; + +import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_test/flutter_test.dart'; + +RemoteMessage _message( + Map data, { + RemoteNotification? notification, +}) => RemoteMessage(data: data, notification: notification); + +void main() { + test('the flat contract maps every field', () { + final content = contentFromMessage( + _message({ + 'channel': 'eew_alert-important-v2', + 'title': '地震速報', + 'body': '花蓮縣近海', + 'id': '42', + }), + ); + + expect(content?.channelKey, 'eew_alert-important-v2'); + expect(content?.title, '地震速報'); + expect(content?.body, '花蓮縣近海'); + expect(content?.id, 42); + }); + + test('the legacy nested payload lands on its own channel', () { + // What the producer still sends: visible text in the FCM notification + // block, structured fields inside data['content'] as one JSON string — + // note channelKey spelled that way, and id as a number. + final content = contentFromMessage( + _message( + { + 'content': + '{"id": ${0xF12345678}, "channelKey": "eq-v2", ' + '"body": "高雄市 能見度 <1 km", "notificationLayout": "BigText"}', + }, + notification: const RemoteNotification( + title: '測試通知', + body: '高雄市(國一N361K) 能見度 <1 km,請注意安全。', + ), + ), + ); + + expect(content?.channelKey, 'eq-v2', reason: 'no announcement fallback'); + // The notification block's text wins over the nested body: the nested copy + // carries `
` where the producer meant a newline. + expect(content?.body, '高雄市(國一N361K) 能見度 <1 km,請注意安全。'); + expect(content?.title, '測試通知'); + expect(content?.id, 0xF12345678); + }); + + test('flat keys win where both shapes exist', () { + final content = contentFromMessage( + _message({ + 'channel': 'report-general-v2', + 'id': '7', + 'content': '{"channelKey": "announcement-general-v2", "id": 9}', + }, notification: const RemoteNotification(title: '報告', body: '內文')), + ); + + expect(content?.channelKey, 'report-general-v2'); + expect(content?.payload?['id'], '7'); + }); + + test('malformed nested JSON degrades to the fallback, never throws', () { + final content = contentFromMessage( + _message({ + 'content': '{not json', + }, notification: const RemoteNotification(title: '公告', body: '內容')), + ); + + expect(content?.channelKey, 'announcement-general-v2'); + expect(content?.title, '公告'); + }); + + test('a message with nothing to show produces no content', () { + // No flat keys, no nested payload, no notification block — there is + // nothing to render, so asking for a channel would be meaningless. + expect(contentFromMessage(_message({})), isNull); + }); + + test('the tap payload carries the resolved channel and id', () { + final content = contentFromMessage( + _message({ + 'content': '{"channelKey": "tsunami-important-v2", "id": 5}', + }, notification: const RemoteNotification(title: '海嘯警報', body: '沿海請注意')), + ); + + expect(content?.payload?['channel'], 'tsunami-important-v2'); + expect(content?.payload?['id'], '5'); + }); +} From b1add834ad409c9852154c27e617591ee2a3130c Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 11:15:45 +0800 Subject: [PATCH 18/40] fix(notify): re-create channels so updated devices hear their own sound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正升級後 Android 通知音效錯亂或靜音——強制重建通道以取得正確的音效資源 Fix(en-US): force channel recreation after app updates so Android plays each alert's own sound instead of a stale cached one --- lib/core/notifications/notification_channels.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/core/notifications/notification_channels.dart b/lib/core/notifications/notification_channels.dart index 459a0e714..7cc733c07 100644 --- a/lib/core/notifications/notification_channels.dart +++ b/lib/core/notifications/notification_channels.dart @@ -14,7 +14,17 @@ abstract final class NotificationChannels { const NotificationChannels._(); /// Bump when any channel definition below changes, to force a re-create. - static const int version = 2; + /// + /// **The bump is not optional paperwork.** Android resolves + /// `resource://raw/` to a numeric resource ID when a channel is first + /// created and caches that number system-side; the docs forbid changing a + /// created channel's behaviour, so the only repair is delete + re-create. + /// Resource IDs are re-assigned whenever the resource set changes — and when + /// the sound files were re-encoded in place (#525) with no bump, upgraded + /// installs kept channels pointing at numbers from an older APK: crossed or + /// silent sounds on every device that had seen the previous build. Bumping + /// this counter is what makes [NotificationService] force-update them. + static const int version = 3; /// Default status-bar icon (Android) — a monochrome drawable. static const String icon = 'resource://drawable/ic_stat_name'; From 355b8b68098d1900d0d208eb656216ac9ac35082 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 11:25:58 +0800 Subject: [PATCH 19/40] fix(notify): clamp oversized push ids instead of dropping the alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正前景收到推播時完全沒有通知——後端的 40-bit 訊息編號超出 32-bit 驗證,改為略過編號照常顯示 Fix(en-US): fix foreground pushes not rendering at all — the backend's 40-bit message id fails awesome's 32-bit validation, so oversized ids are now ignored and the alert still shows --- .../notifications/notification_service.dart | 23 ++++++++++++++--- .../content_from_message_test.dart | 25 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 15bc266d6..e987814df 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -434,9 +434,7 @@ NotificationContent? contentFromMessage(RemoteMessage message) { nestedField('channelKey') ?? _fallbackChannelKey; final id = - int.tryParse((data['id'] as String?) ?? '') ?? - int.tryParse(nestedField('id') ?? '') ?? - 0; + _asNotificationId(data['id']) ?? _asNotificationId(nested?['id']) ?? 0; final idText = (data['id'] as String?) ?? nestedField('id'); return NotificationContent( id: id, @@ -464,6 +462,25 @@ Map? _nestedContent(Map data) { } } +/// Coerces a payload id to the form awesome accepts. +/// +/// awesome validates ids against the **signed 32-bit** range and throws — +/// killing the whole notification — on anything wider. The producer computes +/// its id as a 40-bit hex slice (`parseInt(md5slice, 16)`), so oversized ids +/// are the common case, not the edge: they are treated as absent and the +/// notification renders with id 0, replacing whatever came before it. +int? _asNotificationId(Object? value) { + final parsed = switch (value) { + final int v => v, + final String s => int.tryParse(s), + _ => null, + }; + if (parsed == null || parsed < -0x80000000 || parsed > 0x7FFFFFFF) { + return null; + } + return parsed; +} + /// Displays a background/terminated **data-only** message via awesome (a /// `notification`-payload message is shown by the OS itself). Runs on a /// background isolate, so awesome must be initialized here before use. diff --git a/test/core/notifications/content_from_message_test.dart b/test/core/notifications/content_from_message_test.dart index 3b8139065..a67141eb6 100644 --- a/test/core/notifications/content_from_message_test.dart +++ b/test/core/notifications/content_from_message_test.dart @@ -41,7 +41,7 @@ void main() { _message( { 'content': - '{"id": ${0xF12345678}, "channelKey": "eq-v2", ' + '{"id": 305419896, "channelKey": "eq-v2", ' '"body": "高雄市 能見度 <1 km", "notificationLayout": "BigText"}', }, notification: const RemoteNotification( @@ -56,7 +56,28 @@ void main() { // carries `
` where the producer meant a newline. expect(content?.body, '高雄市(國一N361K) 能見度 <1 km,請注意安全。'); expect(content?.title, '測試通知'); - expect(content?.id, 0xF12345678); + expect(content?.id, 305419896); + }); + + test('an oversized id is dropped instead of killing the notification', () { + // The producer's id is `parseInt(md5.slice(0, 10), 16)` — up to 40 bits, + // routinely wider than the signed 32-bit range awesome validates. Before + // the clamp this threw out of createNotification and the foreground push + // simply never rendered. + final content = contentFromMessage( + _message({ + 'content': + '{"id": ${0xF12345678}, "channelKey": "eq-v2", "body": "測試"}', + }, notification: const RemoteNotification(title: '地震速報', body: '花蓮縣近海')), + ); + + expect(content?.id, 0, reason: 'out of 32-bit range → treated as absent'); + expect( + content?.channelKey, + 'eq-v2', + reason: 'the rest of the payload still applies', + ); + expect(content?.title, '地震速報'); }); test('flat keys win where both shapes exist', () { From f4d38c577a7d56a8362b5fe876c50fd24fa6dbf6 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 11:35:05 +0800 Subject: [PATCH 20/40] fix(notify): rebuild channels on plain keys when the catalogue changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正升級後 Android 背景推播變成系統音效——通道改以原始 key 重建,系統渲染找得到正確類別與音效 Fix(en-US): fix background pushes falling back to the system sound after an update — channels are rebuilt under their plain keys so the system render finds the right category and sound --- .../notifications/notification_channels.dart | 2 +- .../notifications/notification_service.dart | 41 +++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/lib/core/notifications/notification_channels.dart b/lib/core/notifications/notification_channels.dart index 7cc733c07..4a075e0ab 100644 --- a/lib/core/notifications/notification_channels.dart +++ b/lib/core/notifications/notification_channels.dart @@ -24,7 +24,7 @@ abstract final class NotificationChannels { /// installs kept channels pointing at numbers from an older APK: crossed or /// silent sounds on every device that had seen the previous build. Bumping /// this counter is what makes [NotificationService] force-update them. - static const int version = 3; + static const int version = 4; /// Default status-bar icon (Android) — a monochrome drawable. static const String icon = 'resource://drawable/ic_stat_name'; diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index e987814df..fdc95f730 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -272,21 +272,48 @@ class NotificationService { } } - // Android caches a channel's settings after first creation, so a changed - // sound or importance only takes effect with `forceUpdate` — which is what - // the catalogue version is for. + // Android freezes a created channel's behaviour — sound included — so a + // changed definition cannot simply be pushed. awesome's own update path + // makes it worse: a forced update deletes the channel under its plain key + // and recreates it under `_`, an id FCM's system-tray renders + // can never find. The backend's pushes carry an FCM `notification` block, + // so background delivery is exactly that path — and every upgraded install + // fell back to the system default sound. + // + // The catalogue version therefore means **remove everything and register + // again**: `removeChannel` deletes both the plain and the hashed variant + // plus the registry entry, so each re-registration takes the "created" + // branch — plain key, current sound files, no hash suffix ever. final stored = _settings.getInt(SettingKeys.channelVersion) ?? 0; - final force = stored < NotificationChannels.version; + final outdated = stored < NotificationChannels.version; // On the happy path this only runs when the catalogue changed. On the // degraded path it always runs, because it is what registers the channels // the seed did not. - if (force || !registered) { + if (outdated || !registered) { + if (outdated && registered) { + var purged = true; + for (final channel in channels) { + if (rejected.contains(channel.channelKey)) continue; + try { + await AwesomeNotifications().removeChannel(channel.channelKey!); + } catch (error, stackTrace) { + purged = false; + Log.handle(error, stackTrace, 'purging ${channel.channelKey}'); + } + } + if (!purged) { + Log.error( + 'notifications: channel purge incomplete — stale sounds may ' + 'persist on upgraded installs until the next launch', + ); + } + } for (final channel in channels) { if (identical(channel, seed)) continue; if (rejected.contains(channel.channelKey)) continue; try { - await AwesomeNotifications().setChannel(channel, forceUpdate: force); + await AwesomeNotifications().setChannel(channel); } catch (error, stackTrace) { rejected.add(channel.channelKey ?? '?'); Log.handle(error, stackTrace, 'channel ${channel.channelKey}'); @@ -300,7 +327,7 @@ class NotificationService { 'rejected — ${rejected.join(', ')}. The rest are registered.', ); } - if (force) { + if (outdated) { await _settings.setInt( SettingKeys.channelVersion, NotificationChannels.version, From 6008943c0b6346d5ed501bb2bedf3e98ad0e309b Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 11:52:08 +0800 Subject: [PATCH 21/40] fix(notify): stop marking every push as an insistent alarm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正 App 內通知音效不斷循環播放——不再把每則推播標記為持續鳴響的警報類別 Fix(en-US): fix in-app notification sounds repeating endlessly — pushes no longer inherit the insistent alarm flag that replays the sound until opened --- lib/core/notifications/notification_service.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index fdc95f730..6b8407680 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -9,6 +9,7 @@ import 'package:dpip/core/permissions/system_settings.dart'; import 'package:dpip/core/notifications/notification_channels.dart'; import 'package:dpip/core/notifications/notification_tap.dart'; import 'package:dpip/core/notifications/notification_taps.dart'; +import 'package:dpip/core/notifications/plain_channels.dart'; import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -333,6 +334,12 @@ class NotificationService { NotificationChannels.version, ); } + + // FCM renders background pushes itself against the PLAIN channel id, a + // lookup that must not depend on how awesome hashed or re-hashed its own + // channels this launch. Mirror the catalogue under plain keys last, after + // every purge and re-registration above has settled. + await PlainChannels.ensure(channels); } Future _initMessaging() async { @@ -472,7 +479,11 @@ NotificationContent? contentFromMessage(RemoteMessage message) { // symmetrically with the FCM-delivered path. payload: {'channel': channelKey, 'id': idText}, wakeUpScreen: true, - category: NotificationCategory.Alarm, + // Deliberately NO `category: Alarm` here: awesome turns that into + // FLAG_INSISTENT | FLAG_NO_CLEAR, which repeats the channel sound until + // the notification is opened — reported as "the alert loops forever". + // Insistence is a per-channel policy decision, not something every push + // should inherit from a hardcoded default. ); } From 0182bd720eee388b174dc596049d079b52fd77d4 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 11:52:10 +0800 Subject: [PATCH 22/40] fix(notify): mirror plain-key channels for FCM background renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正背景推播變成系統音效——以原始 key 鏡像通知通道,讓 FCM 的系統渲染找得到正確類別 Fix(en-US): fix background pushes playing the system sound — channels are mirrored under their plain keys so FCM's own renderer finds the right one --- .../kotlin/com/exptech/dpip/MainActivity.kt | 3 + .../com/exptech/dpip/PlainChannelsChannel.kt | 98 +++++++++++++++++++ lib/core/notifications/plain_channels.dart | 59 +++++++++++ 3 files changed, 160 insertions(+) create mode 100644 android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt create mode 100644 lib/core/notifications/plain_channels.dart diff --git a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt index aa29937f7..6b4660184 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt @@ -59,5 +59,8 @@ class MainActivity : FlutterActivity() { EventChannel(messenger, CompassChannel.NAME) .setStreamHandler(CompassChannel(applicationContext)) + + MethodChannel(messenger, PlainChannelsChannel.NAME) + .setMethodCallHandler(PlainChannelsChannel(applicationContext)) } } diff --git a/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt new file mode 100644 index 000000000..1a3adec66 --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt @@ -0,0 +1,98 @@ +package com.exptech.dpip + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.media.AudioAttributes +import android.net.Uri +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Mirrors the notification catalogue under **plain, un-hashed channel IDs**. + * + * awesome_notifications derives each Android channel's ID from a hash of its + * model, so the ID a locally-rendered notification targets is not stable + * across builds. FCM's system-tray path cannot follow that dance at all: a + * push carrying an FCM `notification` block is rendered by the SDK itself, + * which looks up `android_channel_id` — the plain key from the backend — + * verbatim, and falls back to the system default channel (system sound) the + * moment it misses. + * + * [ensure] creates any missing plain-key channel straight through + * NotificationManager. An existing channel is never touched — user tuning + * survives ordinary launches, and the Dart-side catalogue-version gate is + * what drives delete + re-create when the sound files themselves change. + */ +class PlainChannelsChannel(private val context: Context) : + MethodChannel.MethodCallHandler { + + companion object { + const val NAME = "com.exptech.dpip/plain_notification_channels" + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "ensure" -> { + val channels = + call.argument>>("channels") ?: emptyList() + result.success(ensure(channels)) + } + + else -> result.notImplemented() + } + } + + private fun ensure(channels: List>): Int { + val manager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + var created = 0 + for (entry in channels) { + val id = entry["id"] as? String ?: continue + // Create-if-missing only: a channel that already exists may carry + // settings the user tuned in the OS UI, and rewriting it would be + // ignored for behaviour anyway — Android freezes created channels. + if (manager.getNotificationChannel(id) != null) continue + + val name = entry["name"] as? String ?: continue + val importance = (entry["importance"] as? Number)?.toInt() + ?: NotificationManager.IMPORTANCE_DEFAULT + + val channel = NotificationChannel(id, name, importance) + (entry["description"] as? String)?.let { channel.description = it } + (entry["group"] as? String)?.let { channel.group = it } + + // Read from the current APK resources, so the URI is correct by + // construction — no stale numeric resource ids, ever. + val sound = entry["sound"] as? String + if (sound != null) { + val resId = + context.resources.getIdentifier(sound, "raw", context.packageName) + if (resId > 0) { + val attributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + channel.setSound( + Uri.parse("android.resource://${context.packageName}/$resId"), + attributes, + ) + } + } + + (entry["vibrationPattern"] as? List<*>)?.let { pattern -> + val longs = pattern.mapNotNull { (it as? Number)?.toLong() } + .toLongArray() + if (longs.isNotEmpty()) channel.vibrationPattern = longs + } + (entry["ledColor"] as? Number)?.let { color -> + channel.enableLights(true) + channel.lightColor = color.toInt() + } + + manager.createNotificationChannel(channel) + created++ + } + return created + } +} diff --git a/lib/core/notifications/plain_channels.dart b/lib/core/notifications/plain_channels.dart new file mode 100644 index 000000000..d4b5153e0 --- /dev/null +++ b/lib/core/notifications/plain_channels.dart @@ -0,0 +1,59 @@ +/// Mirrors the notification catalogue to Android under plain channel keys. +/// +/// FCM renders background pushes **itself** whenever the payload carries an +/// FCM `notification` block, and it resolves `android_channel_id` against +/// plain, un-hashed channel IDs. awesome's own channels are keyed by a hash +/// of their model — invisible to that lookup. Without this mirror, every +/// background push falls back to the system default channel: the system +/// sound, regardless of what the catalogue says. +/// +/// Best-effort by design: a failure leaves background pushes on the fallback +/// channel rather than breaking the app, and the call is a no-op off Android. +library; + +import 'dart:io'; + +import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:flutter/services.dart'; + +abstract final class PlainChannels { + static const _channel = MethodChannel( + 'com.exptech.dpip/plain_notification_channels', + ); + + /// Creates any plain-key channel from [channels] that does not exist yet. + /// + /// Existing channels are never rewritten: Android freezes created channels' + /// behaviour, and the user may have tuned them in the OS UI. Sound refreshes + /// ride the catalogue-version gate instead, which deletes and re-registers. + static Future ensure(List channels) async { + if (!Platform.isAndroid) return; + try { + await _channel.invokeMethod('ensure', { + 'channels': [for (final channel in channels) _payload(channel)], + }); + } on PlatformException catch (error, stackTrace) { + Log.handle(error, stackTrace, 'mirroring plain notification channels'); + } on MissingPluginException { + // An engine that never registered the channel (tests, hot restarts + // into a fresh messenger) simply skips the mirror. + } + } + + static Map _payload(NotificationChannel channel) => { + 'id': channel.channelKey, + 'name': channel.channelName, + if (channel.channelDescription != null) + 'description': channel.channelDescription, + // awesome's importance enum is declared in Android's IMPORTANCE_* order, + // so the index is the constant the native side expects. + 'importance': channel.importance?.index ?? 3, + if (channel.channelGroupKey != null) 'group': channel.channelGroupKey, + if (channel.soundSource != null) + 'sound': channel.soundSource!.replaceAll('resource://raw/', ''), + if (channel.vibrationPattern != null) + 'vibrationPattern': channel.vibrationPattern, + if (channel.ledColor != null) 'ledColor': channel.ledColor!.toARGB32(), + }; +} From 444cb24b2d39376ed1cf9de946bc63efea3d11c6 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 12:04:18 +0800 Subject: [PATCH 23/40] feat(wind): advect the particles inside the map's own GL layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 風場粒子改由地圖原生 GPU 圖層繪製,Android 上更省資源、也為地圖縮放順暢化鋪路 New(en-US): wind particles are now advected by the map's native GPU layer, cutting Android resource cost and paving the way for smoother zooming --- .../layers/wind_forecast_layer.dart | 45 +++- .../layers/wind_particle_native.dart | 234 ++++++++++++++++++ .../layers/wind_particle_sim.dart | 45 ++-- .../widgets/wind_particle_overlay.dart | 29 ++- lib/features/weather/domain/wind_field.dart | 20 ++ pubspec.lock | 12 +- pubspec.yaml | 8 +- .../map/wind_forecast_layer_test.dart | 11 +- .../map/wind_overlay_resilience_test.dart | 12 +- .../map/wind_particle_native_test.dart | 190 ++++++++++++++ 10 files changed, 573 insertions(+), 33 deletions(-) create mode 100644 lib/features/map/presentation/layers/wind_particle_native.dart create mode 100644 test/features/map/wind_particle_native_test.dart diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart index 923a27dee..3484eb2cd 100644 --- a/lib/features/map/presentation/layers/wind_forecast_layer.dart +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -9,6 +9,7 @@ import 'package:dpip/core/logging/log.dart'; import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; import 'package:dpip/features/map/presentation/widgets/forecast_overlay_menu.dart'; import 'package:dpip/features/map/presentation/widgets/wind_particle_overlay.dart'; +import 'package:dpip/features/map/presentation/layers/wind_particle_native.dart'; import 'package:dpip/features/weather/domain/wind_field.dart'; import 'package:dpip/features/weather/domain/wind_forecast_model.dart'; import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; @@ -47,6 +48,19 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { /// field arrives; the overlay starts its animation only once this is set. final ValueNotifier field = ValueNotifier(null); + /// The GPU renderer that draws the particles inside the map. + /// + /// Where it is available it replaces [WindParticleOverlay] entirely. That is + /// not a preference: a Flutter overlay repainting above an Android platform + /// view leaks a full-screen graphics buffer per frame under HCPP, and the + /// particles were the only thing in the app repainting every frame. + late final WindParticleNative particles = WindParticleNative( + field: field, + interacting: interacting, + ); + + bool _particlesAttached = false; + /// Whether a finger is currently on the map. /// /// The particle field is torn down for the whole gesture and reseeded when it @@ -72,12 +86,24 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { /// the camera from it every frame. MapLibreMapController? get mapController => controller; + /// Attaches the native renderer the first time a controller is in hand. + /// + /// Lazily rather than in a constructor: the layer outlives any one platform + /// view, and a controller that has been replaced must not keep a layer bound + /// to the old one. + void _ensureParticles(MapLibreMapController controller) { + if (_particlesAttached || !particles.isSupported) return; + _particlesAttached = true; + particles.attach(controller); + } + @override Future show( MapLibreMapController controller, MapFrame frame, { bool scrubbing = false, }) async { + _ensureParticles(controller); if (scrubbing) { // A megabyte-scale WND1 grid per crossed frame cannot keep up with a // finger. More importantly, displaying the previous grid under a new @@ -147,11 +173,26 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { // The overlay is gone with the layer; stop advertising a field so a // re-attach starts clean rather than animating yesterday's grid. _invalidateField(); + _particlesAttached = false; + await particles.detach(); } @override - Widget buildMapOverlay(BuildContext context) => - WindParticleOverlay(layer: this); + void onSurfaceVisibility(bool visible) { + super.onSurfaceVisibility(visible); + particles.setSurfaceVisible(visible); + } + + /// The Flutter overlay, only where the map cannot draw the particles itself. + /// + /// On Android the native layer carries them and this is deliberately empty — + /// returning the overlay anyway would reintroduce the per-frame Flutter + /// presentation that the whole port exists to remove. It is still the real + /// implementation everywhere else. + @override + Widget buildMapOverlay(BuildContext context) => particles.isActive + ? const SizedBox.shrink() + : WindParticleOverlay(layer: this); /// The particle overlay reads the live camera on every tick, so it never /// needs a rebuild to reproject — and it must not get one: re-keying it on diff --git a/lib/features/map/presentation/layers/wind_particle_native.dart b/lib/features/map/presentation/layers/wind_particle_native.dart new file mode 100644 index 000000000..3352cc00f --- /dev/null +++ b/lib/features/map/presentation/layers/wind_particle_native.dart @@ -0,0 +1,234 @@ +/// Drives the GPU wind-particle layer that lives inside the map. +library; + +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; +import 'package:dpip/features/weather/domain/wind_field.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// Binds [WindForecastMapLayer]'s state to the native particle renderer. +/// +/// The particles used to be a Flutter widget painted over the map. On Android +/// that is a leak with a stopwatch on it: HCPP allocates a full-screen graphics +/// buffer for every Flutter frame presented above a platform view and never +/// returns it, so a ticker-driven overlay took the process from 394 MB to +/// 8 GB of GPU memory in sixteen seconds. Drawn inside the map instead, the +/// particles produce no Flutter frame at all. +/// +/// This class owns only the *conversation* with that renderer — when to add it, +/// what to upload, when to let it run. The simulation itself is gone from Dart; +/// [WindParticleSim] survives as the numeric oracle its GLSL twin is checked +/// against, not as something that runs in production. +class WindParticleNative { + WindParticleNative({ + required this.field, + required this.interacting, + TargetPlatform? platform, + }) : _platform = platform ?? defaultTargetPlatform; + + /// The grid to animate; null while the timeline moves or before it arrives. + final ValueListenable field; + + /// Whether a finger is on the map. + final ValueListenable interacting; + + final TargetPlatform _platform; + + MapLibreMapController? _controller; + WindField? _uploaded; + bool _added = false; + bool _playing = false; + bool _visible = true; + bool _unavailable = false; + + /// Serialises every native call. + /// + /// Ordering is the whole contract here: an `add` that lands after its own + /// `setField` uploads into a layer that does not exist yet, and a `remove` + /// that overtakes a `setPlaying` leaves the map stuck in continuous + /// rendering. Awaiting each call in turn is cheap — there are a handful per + /// minute, none of them per frame. + Future _queue = Future.value(); + + /// Whether the native renderer is carrying the particles. + /// + /// False on platforms without it, and false after the device refused the + /// layer — the caller falls back to its Flutter overlay rather than showing + /// nothing. + bool get isActive => _added && !_unavailable; + + /// Whether this platform should even try. + /// + /// Android only, for now. The leak this exists to avoid is in the Android + /// SurfaceControl path, and iOS would need the whole shader set rewritten in + /// Metal for a problem it does not have. + bool get isSupported => _platform == TargetPlatform.android; + + void attach(MapLibreMapController controller) { + if (!isSupported || _unavailable) return; + _controller = controller; + field.addListener(_onFieldChanged); + interacting.addListener(_onInteractingChanged); + _enqueue(() async { + await controller.addWindParticleLayer(); + _added = true; + await controller.setWindParticleTuning( + windParticleTuning(pixelRatio: _devicePixelRatio()), + ); + await _pushField(controller); + await _pushPlaying(controller); + }); + } + + /// Releases the native layer. Safe to call when nothing was ever added. + Future detach() async { + field.removeListener(_onFieldChanged); + interacting.removeListener(_onInteractingChanged); + final controller = _controller; + _controller = null; + if (!_added || controller == null) { + _added = false; + _playing = false; + _uploaded = null; + return; + } + _added = false; + _playing = false; + _uploaded = null; + _enqueue(() => controller.removeWindParticleLayer()); + await _queue; + } + + /// The hosting surface was hidden or revealed. + void setSurfaceVisible(bool visible) { + if (_visible == visible) return; + _visible = visible; + _sync(); + } + + void _onFieldChanged() { + final controller = _controller; + if (controller == null) return; + _enqueue(() async { + await _pushField(controller); + await _pushPlaying(controller); + }); + } + + void _onInteractingChanged() => _sync(); + + void _sync() { + final controller = _controller; + if (controller == null) return; + _enqueue(() => _pushPlaying(controller)); + } + + Future _pushField(MapLibreMapController controller) async { + final current = field.value; + if (identical(current, _uploaded)) return; + _uploaded = current; + final payload = current == null ? null : windFieldPayload(current); + if (payload == null) return; + await controller.setWindParticleField(payload); + } + + /// The animation runs only when there is something to animate and someone to + /// see it. A gesture no longer stops it: the particle count is fixed on the + /// GPU, so a pinch changes how many are drawn rather than reseeding the + /// population, and there is nothing left for hiding them to protect. + Future _pushPlaying(MapLibreMapController controller) async { + final want = _visible && _uploaded?.source != null; + if (want == _playing) return; + _playing = want; + await controller.setWindParticlePlaying(want); + } + + void _enqueue(Future Function() action) { + _queue = _queue.then((_) async { + if (_unavailable) return; + try { + await action(); + } on PlatformException catch (error) { + if (error.code == 'WIND_LAYER_UNAVAILABLE') { + // The device cannot host the layer — texture mode is on, so the map + // has no SurfaceView to draw into. Stop trying and let the caller + // fall back; this is a configuration difference, not a failure. + _unavailable = true; + _added = false; + Log.warning('Wind particle layer unavailable: ${error.message}'); + return; + } + Log.warning('Wind particle layer call failed: ${error.message}'); + } catch (error) { + Log.warning('Wind particle layer call failed: $error'); + } + }); + } +} + +/// The display scale the point size is expressed in. +/// +/// `pointSize` is tuned in logical pixels, but `gl_PointSize` is in physical +/// ones — on a 3x phone an unconverted value draws the field a third of its +/// intended weight, which reads as "the wind is faint today" rather than as a +/// bug. Read from the dispatcher rather than a `BuildContext` because the layer +/// attaches from a controller callback, where there is no element to look up. +double _devicePixelRatio() { + final views = ui.PlatformDispatcher.instance.views; + return views.isEmpty ? 1 : views.first.devicePixelRatio; +} + +/// The zoom curves as their endpoints, in the wire's shape. +/// +/// Sent whole so the numbers keep one home. Evaluating them per zoom in Dart +/// would put a platform call on the camera path — the exact per-frame traffic +/// this design exists to remove — and would leave two copies of the curve to +/// drift apart. `wind_particle_sim.dart` stays their definition; this is a +/// projection of it. +Map windParticleTuning({double pixelRatio = 1}) => { + 'zoomLo': kWindZoomLo, + 'zoomHi': kWindZoomHi, + 'particlesLo': kWindParticles.$1, + 'particlesHi': kWindParticles.$2, + 'pointSizeLo': kWindPointSize.$1, + 'pointSizeHi': kWindPointSize.$2, + 'speedFactorLo': kWindSpeedFactor.$1, + 'speedFactorHi': kWindSpeedFactor.$2, + 'fadeOpacityLo': kWindFadeOpacity.$1, + 'fadeOpacityHi': kWindFadeOpacity.$2, + 'dropRate': kWindDropRate, + 'densityCalm': kWindDensityCalm, + 'densityStrong': kWindDensityStrong, + 'speedScale': kWindSpeedScale, + 'pixelRatio': pixelRatio, +}; + +/// One wind field in the wire's shape, or null when it carries no payload. +/// +/// The raw WND1 body goes over untouched, with the header Dart already parsed +/// alongside it. Native does not re-parse: one parser, one place, one set of +/// tests. A field assembled in code rather than decoded has no body and is +/// skipped rather than faked. +Map? windFieldPayload(WindField field) { + final source = field.source; + if (source == null) return null; + return { + 'bytes': source, + 'planeOffset': field.planeOffset, + 'width': field.width, + 'height': field.height, + 'lat0': field.lat0, + 'lon0': field.lon0, + 'dLat': field.dLat, + 'dLon': field.dLon, + 'uMin': field.uMin, + 'uMax': field.uMax, + 'vMin': field.vMin, + 'vMax': field.vMax, + }; +} diff --git a/lib/features/map/presentation/layers/wind_particle_sim.dart b/lib/features/map/presentation/layers/wind_particle_sim.dart index 2b3710291..d701e8d07 100644 --- a/lib/features/map/presentation/layers/wind_particle_sim.dart +++ b/lib/features/map/presentation/layers/wind_particle_sim.dart @@ -17,24 +17,31 @@ import 'package:dpip/features/weather/domain/wind_field.dart'; /// Wind speed (m/s) that saturates the visual ramp — fixed across every frame /// and both models so a streak means the same thing everywhere /// (`web/wind.js`, `SPEED_SCALE`). +/// The tuning curves below are **public because they are now a wire contract**. +/// +/// The simulation they were written for no longer runs in production — the +/// particles are advected on the GPU inside the map. These constants are sent +/// to that renderer as endpoints and interpolated there with the same rules, +/// so they keep exactly one definition. This file stays as the numeric oracle +/// the GLSL is checked against; see `wind_particle_native.dart`. const double kWindSpeedScale = 32; /// The zooms the tuned values are pinned at (`web/index.html`, `ZOOM_STOPS`), /// which are also this layer's own limits. -const double _kZoomLo = 3; -const double _kZoomHi = 7; +const double kWindZoomLo = 3; +const double kWindZoomHi = 7; // Each pair is (value at z3, value at z7). Which ones interpolate // geometrically and which linearly is not a free choice either — it is what // `TUNE` declares, and a count that steps 6400 → 4096 → 2601 is a visibly // different field from one that steps 6400 → 5056 → 3712. -const (double, double) _kParticles = (6400, 1024); // log -const (double, double) _kPointSize = (1.5, 1.8); // lin, logical px -const (double, double) _kSpeedFactor = (0.2, 0.0151); // log -const (double, double) _kFadeOpacity = (0.95, 0.945); // lin +const (double, double) kWindParticles = (6400, 1024); // log +const (double, double) kWindPointSize = (1.5, 1.8); // lin, logical px +const (double, double) kWindSpeedFactor = (0.2, 0.0151); // log +const (double, double) kWindFadeOpacity = (0.95, 0.945); // lin /// Chance per frame that a particle in good standing is recycled anyway. -const double _kDropRate = 0.011; +const double kWindDropRate = 0.011; /// Relative density of particles in still air and in strong wind. /// @@ -42,15 +49,15 @@ const double _kDropRate = 0.011; /// to say. See the note in `web/index.html`: once the colour underneath /// carries the speed, thinning the streaks where the weather is spends the one /// channel still describing direction. -const double _kDensityCalm = 0.5; -const double _kDensityStrong = 5.5; +const double kWindDensityCalm = 0.5; +const double kWindDensityStrong = 5.5; /// Where a zoom sits between the two tuned stops. /// /// Clamped rather than extrapolated, matching the web's `effective()`: outside /// the stops there is no judgement behind the number, only arithmetic. double _stopFraction(double zoom) => - ((zoom - _kZoomLo) / (_kZoomHi - _kZoomLo)).clamp(0.0, 1.0); + ((zoom - kWindZoomLo) / (kWindZoomHi - kWindZoomLo)).clamp(0.0, 1.0); double _lerpStops((double, double) v, double zoom) { final f = _stopFraction(zoom); @@ -68,14 +75,17 @@ double _logLerpStops((double, double) v, double zoom) { /// particle state and rounds to one; matching the count matters more than the /// squareness, but rounding the same way keeps the two exactly equal. int particleCountFor(double zoom) { - final edge = math.max(1, math.sqrt(_logLerpStops(_kParticles, zoom)).round()); + final edge = math.max( + 1, + math.sqrt(_logLerpStops(kWindParticles, zoom)).round(), + ); return edge * edge; } /// Diameter of a particle in logical pixels. The web sets `gl_PointSize` in /// device pixels and multiplies by the device pixel ratio to get there, so the /// tuned number is already the logical one. -double pointSizeFor(double zoom) => _lerpStops(_kPointSize, zoom); +double pointSizeFor(double zoom) => _lerpStops(kWindPointSize, zoom); /// What fraction of the trail buffer survives each frame. /// @@ -87,21 +97,22 @@ double pointSizeFor(double zoom) => _lerpStops(_kPointSize, zoom); /// /// Never 1: a fade that does not fade accumulates for ever and the screen /// saturates to white. -double fadeOpacityFor(double zoom) => _lerpStops(_kFadeOpacity, zoom); +double fadeOpacityFor(double zoom) => _lerpStops(kWindFadeOpacity, zoom); /// Field-space distance a particle rides per (m/s · frame) at [zoom]. /// /// It has to fall with zoom: a field-space step is a fraction of the *world*, /// so the pixels it covers double with every zoom level in. Held constant at /// the value that suits z3, particles at z7 move 23× too fast. -double fieldStepFor(double zoom) => 0.0001 * _logLerpStops(_kSpeedFactor, zoom); +double fieldStepFor(double zoom) => + 0.0001 * _logLerpStops(kWindSpeedFactor, zoom); /// The web's `densityWeight`: how many particles a place should hold relative /// to spreading them evenly, by how hard the wind is blowing there. double densityWeight(double speed) { final t = (speed / (0.6 * kWindSpeedScale)).clamp(0.0, 1.0); final s = t * t * (3 - 2 * t); // smoothstep - return _kDensityCalm + (_kDensityStrong - _kDensityCalm) * s; + return kWindDensityCalm + (kWindDensityStrong - kWindDensityCalm) * s; } /// The camera a wind overlay is drawn under — enough to project a lat/lng to @@ -375,7 +386,7 @@ class WindParticleSim { // Recycle a particle that has left, and occasionally a healthy one — the // field would otherwise empty out of wherever the density weighting is // not putting anything back. - if (!inView || _random.nextDouble() < _kDropRate) { + if (!inView || _random.nextDouble() < kWindDropRate) { _respawn(p, fieldSpace); } } @@ -518,7 +529,7 @@ class WindParticleSim { final (u, v) = _sampleUV(x, y); final weight = densityWeight(math.sqrt(u * u + v * v)); if (_random.nextDouble() < - weight / math.max(_kDensityCalm, _kDensityStrong)) { + weight / math.max(kWindDensityCalm, kWindDensityStrong)) { p.x = x; p.y = y; } diff --git a/lib/features/map/presentation/widgets/wind_particle_overlay.dart b/lib/features/map/presentation/widgets/wind_particle_overlay.dart index 104d8f602..2774a4ca3 100644 --- a/lib/features/map/presentation/widgets/wind_particle_overlay.dart +++ b/lib/features/map/presentation/widgets/wind_particle_overlay.dart @@ -15,6 +15,7 @@ import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart'; import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; import 'package:dpip/features/map/presentation/pages/map_page.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -38,6 +39,28 @@ import 'package:flutter/scheduler.dart'; class WindParticleOverlay extends StatefulWidget { const WindParticleOverlay({super.key, required this.layer}); + /// Whether this platform may run the ticker at all. **Temporary containment, + /// not a preference** — remove it with the Flutter overlay itself. + /// + /// Android's HCPP platform-view mode leaks one full-screen HardwareBuffer + /// (10.47 MB) for every Flutter frame presented above the map, and a + /// ticker-driven overlay presents one every frame. Measured on a Pixel 9: + /// 394 -> 8042 MB of GPU memory in 16 s, then lmkd killed the process — which + /// takes the earthquake and radar monitoring down with it. A missing + /// animation is the cheaper failure. iOS is unaffected; the leak is in the + /// Android SurfaceControl/AHB swapchain path. + /// + /// Delete this once the particles live in a MapLibre layer, or once the + /// engine bounds `AHBTexturePoolVK` again — still unbounded at 3.47.1, the + /// 3.48 beta and master. See `android/app/src/main/AndroidManifest.xml`. + /// + /// Tests set this true: the simulation, the trail buffer and the ticker + /// lifecycle are all still live code that the MapLibre port has to match, so + /// their coverage must not lapse while the containment is in place. + @visibleForTesting + static bool animateOnThisPlatform = + defaultTargetPlatform != TargetPlatform.android; + final WindForecastMapLayer layer; @override @@ -155,7 +178,11 @@ class _WindParticleOverlayState extends State /// Whether the animation should be running at all: there is a field, the map /// tab is on screen, and no gesture is in progress. bool get _shouldAnimate => - _sim != null && _visible && _appForeground && !_interacting; + WindParticleOverlay.animateOnThisPlatform && + _sim != null && + _visible && + _appForeground && + !_interacting; /// Runs the ticker only while [_shouldAnimate]. void _updateTicker() { diff --git a/lib/features/weather/domain/wind_field.dart b/lib/features/weather/domain/wind_field.dart index edd9a4380..bdf6bd68a 100644 --- a/lib/features/weather/domain/wind_field.dart +++ b/lib/features/weather/domain/wind_field.dart @@ -40,6 +40,8 @@ class WindField { required this.model, required this.u, required this.v, + this.source, + this.planeOffset = 0, }); /// Cells across — the field spans `dLon × width` degrees of longitude. @@ -80,6 +82,22 @@ class WindField { /// Quantised northward component, `width × height`, raster order. final Uint8List v; + /// The undecoded WND1 body [u] and [v] are views into. + /// + /// Kept so the GPU renderer can be handed the payload untouched instead of a + /// re-serialised copy: the planes are already in the raster order a texture + /// upload wants, and re-packing 2 MB per forecast frame to send the same + /// bytes back out would be pure loss. It costs nothing to retain — the views + /// pin the buffer regardless. + /// + /// Null for a field assembled in code rather than decoded from the wire — + /// there is no payload to upload, and the GPU renderer skips it rather than + /// inventing one. + final Uint8List? source; + + /// Byte offset of the u plane within [source]; the v plane follows it. + final int planeOffset; + /// Parses a WND1 payload. Throws [FormatException] on any structural /// mismatch (bad magic, unsupported version, truncation) — the data layer /// wraps that into a [DecodeFailure] before anything else sees it. @@ -118,6 +136,8 @@ class WindField { model: String.fromCharCodes(bytes.sublist(67, planeOffset)), u: Uint8List.sublistView(bytes, planeOffset, planeOffset + n), v: Uint8List.sublistView(bytes, planeOffset + n, planeOffset + n * 2), + source: bytes, + planeOffset: planeOffset, ); } diff --git a/pubspec.lock b/pubspec.lock index cc065ca59..755812d49 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -736,8 +736,8 @@ packages: dependency: "direct main" description: path: maplibre_gl - ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c - resolved-ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c + ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" + resolved-ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git version: "0.26.2" @@ -745,8 +745,8 @@ packages: dependency: "direct main" description: path: maplibre_gl_platform_interface - ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c - resolved-ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c + ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" + resolved-ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git version: "0.26.2" @@ -754,8 +754,8 @@ packages: dependency: "direct overridden" description: path: maplibre_gl_web - ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c - resolved-ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c + ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" + resolved-ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git version: "0.26.2" diff --git a/pubspec.yaml b/pubspec.yaml index a52154157..41301ca61 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,13 +58,13 @@ dependencies: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl - ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c + ref: 61a5fd658976ec0f4be838da71bc271a3e9fc7b8 # Direct (not just override) so tests can import the platform interface. maplibre_gl_platform_interface: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl_platform_interface - ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c + ref: 61a5fd658976ec0f4be838da71bc271a3e9fc7b8 # LoRa mesh (Meshtastic) over BLE — off-grid emergency messaging. Requires # Bluetooth + location permissions (Android manifest / iOS Info.plist below). # Vendored (third_party/) with two upstream fixes: requestMtu is skipped off @@ -123,12 +123,12 @@ dependency_overrides: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl_platform_interface - ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c + ref: 61a5fd658976ec0f4be838da71bc271a3e9fc7b8 maplibre_gl_web: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl_web - ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c + ref: 61a5fd658976ec0f4be838da71bc271a3e9fc7b8 flutter: config: diff --git a/test/features/map/wind_forecast_layer_test.dart b/test/features/map/wind_forecast_layer_test.dart index 4463f68fb..0016a98e7 100644 --- a/test/features/map/wind_forecast_layer_test.dart +++ b/test/features/map/wind_forecast_layer_test.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart'; @@ -10,6 +9,7 @@ import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_layer_category.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -87,6 +87,15 @@ WindField _windField(String marker) => WindField( ); void main() { + // The Android containment for the HCPP buffer leak is off for these tests: + // the simulation, trail buffer and ticker lifecycle are still live code that + // the MapLibre particle layer has to reproduce, so their coverage stays on. + setUp(() => WindParticleOverlay.animateOnThisPlatform = true); + tearDown( + () => WindParticleOverlay.animateOnThisPlatform = + defaultTargetPlatform != TargetPlatform.android, + ); + test('frames chronological', () async { final layer = WindForecastMapLayer( _FakeWindRepository(['1700000600', '1700000000']), diff --git a/test/features/map/wind_overlay_resilience_test.dart b/test/features/map/wind_overlay_resilience_test.dart index 37553ab6d..15bf97ec7 100644 --- a/test/features/map/wind_overlay_resilience_test.dart +++ b/test/features/map/wind_overlay_resilience_test.dart @@ -10,8 +10,6 @@ /// is exactly what a user sees after holding the map a while. library; -import 'dart:typed_data'; - import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart'; @@ -19,6 +17,7 @@ import 'package:dpip/features/map/presentation/widgets/wind_particle_overlay.dar import 'package:dpip/features/weather/domain/wind_field.dart'; import 'package:dpip/features/weather/domain/wind_forecast_model.dart'; import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -115,6 +114,15 @@ Future<(WindForecastMapLayer, _FlakyController)> _mount( } void main() { + // The Android containment for the HCPP buffer leak is off for these tests: + // the simulation, trail buffer and ticker lifecycle are still live code that + // the MapLibre particle layer has to reproduce, so their coverage stays on. + setUp(() => WindParticleOverlay.animateOnThisPlatform = true); + tearDown( + () => WindParticleOverlay.animateOnThisPlatform = + defaultTargetPlatform != TargetPlatform.android, + ); + testWidgets('a throwing frame does not stop the animation', (tester) async { final (_, controller) = await _mount(tester); await tester.pump(const Duration(milliseconds: 16)); diff --git a/test/features/map/wind_particle_native_test.dart b/test/features/map/wind_particle_native_test.dart new file mode 100644 index 000000000..fbbf87405 --- /dev/null +++ b/test/features/map/wind_particle_native_test.dart @@ -0,0 +1,190 @@ +import 'package:dpip/features/map/presentation/layers/wind_particle_native.dart'; +import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; +import 'package:dpip/features/weather/domain/wind_field.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A WND1 body with a real header and two tiny planes, so the payload under +/// test is the one a decode actually produces rather than a hand-built stand-in. +Uint8List _wnd1({int width = 4, int height = 3, String model = 'ecmwf'}) { + final name = model.codeUnits; + final n = width * height; + final bytes = Uint8List(67 + name.length + n * 2); + final data = ByteData.sublistView(bytes); + bytes.setRange(0, 4, 'WND1'.codeUnits); + data.setUint16(4, 1, Endian.little); + data.setUint16(6, width, Endian.little); + data.setUint16(8, height, Endian.little); + data.setFloat64(10, 90, Endian.little); // lat0 + data.setFloat64(18, 180, Endian.little); // lon0 — ECMWF starts at 180 + data.setFloat64(26, -0.5, Endian.little); // dLat + data.setFloat64(34, 0.25, Endian.little); // dLon + data.setFloat32(42, -30, Endian.little); // uMin + data.setFloat32(46, 30, Endian.little); // uMax + data.setFloat32(50, -20, Endian.little); // vMin + data.setFloat32(54, 20, Endian.little); // vMax + data.setUint64(58, 1787518800, Endian.little); + data.setUint8(66, name.length); + bytes.setRange(67, 67 + name.length, name); + for (var i = 0; i < n * 2; i++) { + bytes[67 + name.length + i] = i & 0xFF; + } + return bytes; +} + +void main() { + group('the tuning wire', () { + test('carries every curve the simulation defines', () { + final tuning = windParticleTuning(); + expect(tuning['zoomLo'], kWindZoomLo); + expect(tuning['zoomHi'], kWindZoomHi); + expect(tuning['particlesLo'], kWindParticles.$1); + expect(tuning['particlesHi'], kWindParticles.$2); + expect(tuning['pointSizeLo'], kWindPointSize.$1); + expect(tuning['pointSizeHi'], kWindPointSize.$2); + expect(tuning['speedFactorLo'], kWindSpeedFactor.$1); + expect(tuning['speedFactorHi'], kWindSpeedFactor.$2); + expect(tuning['fadeOpacityLo'], kWindFadeOpacity.$1); + expect(tuning['fadeOpacityHi'], kWindFadeOpacity.$2); + expect(tuning['dropRate'], kWindDropRate); + expect(tuning['densityCalm'], kWindDensityCalm); + expect(tuning['densityStrong'], kWindDensityStrong); + expect(tuning['speedScale'], kWindSpeedScale); + }); + + test('sends endpoints, never a per-zoom evaluation', () { + final tuning = windParticleTuning(); + // Every curve must arrive as its two ends. A single value would mean + // Dart evaluated it — which puts a platform call on the camera path and + // leaves two copies of the curve to drift apart. + for (final base in const [ + 'particles', + 'pointSize', + 'speedFactor', + 'fadeOpacity', + ]) { + expect(tuning, contains('${base}Lo'), reason: base); + expect(tuning, contains('${base}Hi'), reason: base); + expect( + tuning.containsKey(base), + isFalse, + reason: '$base must not be pre-evaluated', + ); + } + }); + + test('every value is finite — a NaN would silently stop the field', () { + for (final entry in windParticleTuning().entries) { + expect(entry.value.isFinite, isTrue, reason: entry.key); + } + }); + + test('the point size scales with the device pixel ratio', () { + expect(windParticleTuning(pixelRatio: 3)['pixelRatio'], 3); + expect(windParticleTuning()['pixelRatio'], 1); + }); + }); + + group('the field wire', () { + test('hands over the undecoded body and the header beside it', () { + final bytes = _wnd1(); + final field = WindField.fromWnd1(bytes); + final payload = windFieldPayload(field)!; + + expect( + identical(payload['bytes'], bytes), + isTrue, + reason: + 'the WND1 body goes over untouched — re-serialising 2 MB to send ' + 'back the same bytes is pure loss, and a second parser is a second ' + 'place for the format to drift', + ); + expect(payload['planeOffset'], 67 + 'ecmwf'.length); + expect(payload['width'], 4); + expect(payload['height'], 3); + expect(payload['lat0'], 90.0); + expect(payload['lon0'], 180.0); + expect(payload['dLat'], -0.5); + expect(payload['uMin'], -30.0); + expect(payload['uMax'], 30.0); + expect(payload['vMin'], -20.0); + expect(payload['vMax'], 20.0); + }); + + test('the plane offset points at the u plane the decoder used', () { + final bytes = _wnd1(); + final field = WindField.fromWnd1(bytes); + final payload = windFieldPayload(field)!; + final offset = payload['planeOffset']! as int; + final n = field.width * field.height; + expect( + bytes.sublist(offset, offset + n), + field.u, + reason: 'native reads u from this offset; it must be the same plane', + ); + expect(bytes.sublist(offset + n, offset + n * 2), field.v); + }); + + test('a field built in code has no payload and is skipped', () { + final field = WindField( + width: 2, + height: 2, + lat0: 90, + lon0: 0, + dLat: -1, + dLon: 1, + uMin: -1, + uMax: 1, + vMin: -1, + vMax: 1, + timeMs: 0, + model: 'test', + u: Uint8List(4), + v: Uint8List(4), + ); + expect( + windFieldPayload(field), + isNull, + reason: 'there is no WND1 body to upload — do not invent one', + ); + }); + }); + + group('platform support', () { + WindParticleNative build(TargetPlatform platform) => WindParticleNative( + field: ValueNotifier(null), + interacting: ValueNotifier(false), + platform: platform, + ); + + test('Android is the platform this exists for', () { + expect(build(TargetPlatform.android).isSupported, isTrue); + }); + + test('nowhere else, and nothing is active before it attaches', () { + for (final platform in const [ + TargetPlatform.iOS, + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.linux, + TargetPlatform.fuchsia, + ]) { + final native = build(platform); + expect(native.isSupported, isFalse, reason: '$platform'); + expect(native.isActive, isFalse, reason: '$platform'); + } + expect( + build(TargetPlatform.android).isActive, + isFalse, + reason: + 'the caller keeps its own overlay until the device has actually ' + 'accepted the layer', + ); + }); + + test('detaching without ever attaching is a no-op, not a crash', () async { + await expectLater(build(TargetPlatform.iOS).detach(), completes); + await expectLater(build(TargetPlatform.android).detach(), completes); + }); + }); +} From 9af780e0efd6912a2ac805d22e4b39f6178a3d99 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 12:04:30 +0800 Subject: [PATCH 24/40] build(android): enable hybrid composition for the map platform view Platform: android --- android/app/src/main/AndroidManifest.xml | 50 +++++++++++++++++------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 16e10ab14..88027c5ab 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -43,23 +43,43 @@ compositing to the OS through Vulkan and SurfaceControl instead. Requires API 34+, Vulkan and Impeller; below that the engine falls back on its own, so this is safe to ship. Remove the line to revert. --> - + + android:value="true" /> Date: Mon, 24 Aug 2026 12:48:22 +0800 Subject: [PATCH 25/40] refactor(changelog): name install sources by their update channel --- lib/core/platform/install_source.dart | 28 ++++++++++++++----- .../changelog/domain/update_destination.dart | 3 +- .../presentation/widgets/update_prompt.dart | 4 ++- .../features/changelog/update_check_test.dart | 6 ++-- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/lib/core/platform/install_source.dart b/lib/core/platform/install_source.dart index 0adb28ead..b8d5bad27 100644 --- a/lib/core/platform/install_source.dart +++ b/lib/core/platform/install_source.dart @@ -7,8 +7,10 @@ /// all. So the destination follows the installer, not `Platform.isIOS`. /// /// Native detection is cheap and definitive on both platforms: iOS reads the -/// App Store receipt's filename (`sandboxReceipt` **is** the TestFlight -/// marker), Android reads the installing package name. +/// App Store receipt's filename (`sandboxReceipt` **is** the pre-release +/// marker, narrowed to TestFlight by the absence of an embedded provisioning +/// profile — see `DeviceInfoPlugin.installSource`), Android reads the +/// installing package name. library; import 'package:dpip/core/logging/log.dart'; @@ -25,11 +27,22 @@ enum InstallSource { /// Android, installed by the Play Store. playStore, - /// Installed by something else: a sideloaded APK, an Xcode/`flutter run` - /// build, another Android store. There is no store page to send it to. - sideload, + /// A local development build: `flutter run`, Xcode, an adb-installed debug + /// APK. Detected by the DEBUG compilation condition on iOS and + /// `FLAG_DEBUGGABLE` on Android — both are build facts, not installer + /// records, so they work even though adb leaves no installer behind. + development, - /// Detection failed or has not run. Treated as [sideload] for destinations, + /// Installed outside any store: a GitHub release APK/IPA, a re-signed IPA, + /// another Android store. There is no store page to send it to. + /// + /// Named for the channel rather than the act: neither platform reveals + /// *where* a manually installed package was downloaded from, only that no + /// store did it — but every such install takes its updates from the same + /// place, the GitHub release page, so that is what the name says. + github, + + /// Detection failed or has not run. Treated as [github] for destinations, /// and as the stable channel for update checks. unknown; @@ -70,7 +83,8 @@ abstract final class InstallSourceService { 'appStore' => InstallSource.appStore, 'testFlight' => InstallSource.testFlight, 'playStore' => InstallSource.playStore, - 'sideload' => InstallSource.sideload, + 'development' => InstallSource.development, + 'sideload' || 'github' => InstallSource.github, _ => InstallSource.unknown, }; } diff --git a/lib/features/changelog/domain/update_destination.dart b/lib/features/changelog/domain/update_destination.dart index 80d7fdbba..d0217bdd0 100644 --- a/lib/features/changelog/domain/update_destination.dart +++ b/lib/features/changelog/domain/update_destination.dart @@ -54,7 +54,8 @@ UpdateDestination updateDestinationFor( scheme: 'market://details?id=$_androidPackage', web: 'https://play.google.com/store/apps/details?id=$_androidPackage', ); - case InstallSource.sideload: + case InstallSource.development: + case InstallSource.github: case InstallSource.unknown: final url = releaseUrl.isEmpty ? 'https://github.com/ExpTechTW/DPIP/releases' diff --git a/lib/features/changelog/presentation/widgets/update_prompt.dart b/lib/features/changelog/presentation/widgets/update_prompt.dart index 275708466..8e027be12 100644 --- a/lib/features/changelog/presentation/widgets/update_prompt.dart +++ b/lib/features/changelog/presentation/widgets/update_prompt.dart @@ -137,7 +137,9 @@ class _UpdatePromptState extends State { InstallSource.appStore => l10n.updateOpenAppStore, InstallSource.testFlight => l10n.updateOpenTestFlight, InstallSource.playStore => l10n.updateOpenPlayStore, - InstallSource.sideload || InstallSource.unknown => l10n.updateDownload, + InstallSource.development || + InstallSource.github || + InstallSource.unknown => l10n.updateDownload, }; Future _openStore(InstallSource source, String releaseUrl) async { diff --git a/test/features/changelog/update_check_test.dart b/test/features/changelog/update_check_test.dart index 52f906d9c..621374403 100644 --- a/test/features/changelog/update_check_test.dart +++ b/test/features/changelog/update_check_test.dart @@ -203,17 +203,17 @@ void main() { } }); - test('a sideload updates from the release page it came from', () { + test('a github install updates from the release page it came from', () { const url = 'https://github.com/ExpTechTW/DPIP/releases/tag/v3.9.9'; final destination = updateDestinationFor( - InstallSource.sideload, + InstallSource.github, releaseUrl: url, ); expect(destination.scheme, url); expect(destination.web, url); // With no release page, the listing still works. expect( - updateDestinationFor(InstallSource.sideload).web, + updateDestinationFor(InstallSource.github).web, 'https://github.com/ExpTechTW/DPIP/releases', ); }); From 956d44072edd18e9395cb5dfba21ca0d5907baeb Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 24 Aug 2026 12:48:34 +0800 Subject: [PATCH 26/40] fix(ios): stop mistaking dev and re-signed installs for store ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正開發版與重簽安裝被誤判成 App Store 或 TestFlight 版本的問題 Fix(en-US): dev builds and re-signed installs are no longer taken for app store or testflight ones --- ios/Runner.xcodeproj/project.pbxproj | 1 + ios/Runner/DeviceInfoPlugin.swift | 28 +++++++++++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index aea27e133..bdcdea90b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -671,6 +671,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/DeviceInfoPlugin.swift b/ios/Runner/DeviceInfoPlugin.swift index 4f03a7587..017f5705a 100644 --- a/ios/Runner/DeviceInfoPlugin.swift +++ b/ios/Runner/DeviceInfoPlugin.swift @@ -37,17 +37,31 @@ public class DeviceInfoPlugin: NSObject, FlutterPlugin { /// Where this build came from — which decides where an update prompt sends /// the user. /// - /// The App Store receipt's filename is the marker: TestFlight (and a debug - /// build run from Xcode) gets a `sandboxReceipt`, an App Store install gets - /// `receipt`. A DEBUG build is never a store install, so it is reported as a - /// sideload rather than as TestFlight, which would otherwise put every + /// The App Store receipt's filename is the first marker: TestFlight (and a + /// debug build run from Xcode) gets a `sandboxReceipt`, an App Store install + /// gets `receipt`. A DEBUG build is never a store install, so it is reported + /// as a sideload rather than as TestFlight, which would otherwise put every /// developer on the beta channel. + /// + /// On its own `sandboxReceipt` cannot separate TestFlight from a locally + /// signed install — the GitHub release IPA re-signed by AltStore or + /// Sideloadly carries one too, and that user must land on the GitHub release + /// page, not a TestFlight app that holds no DPIP update for them. The + /// discriminator is the embedded provisioning profile: App Store Connect + /// strips it from TestFlight builds, while any profile-signed bundle keeps + /// it. private static func installSource() -> String { #if DEBUG - return "sideload" + return "development" #else - guard let receipt = Bundle.main.appStoreReceiptURL else { return "sideload" } - return receipt.lastPathComponent == "sandboxReceipt" ? "testFlight" : "appStore" + guard let receipt = Bundle.main.appStoreReceiptURL else { return "github" } + // A simulator has no App Store; its receipt lives under CoreSimulator + // and would otherwise read as an App Store install. + if receipt.path.contains("CoreSimulator") { return "development" } + guard receipt.lastPathComponent == "sandboxReceipt" else { return "appStore" } + return Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil + ? "testFlight" + : "sideload" #endif } From 851f6747ab51d091f7d758c8ff4aca079bf32070 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 24 Aug 2026 12:48:43 +0800 Subject: [PATCH 27/40] refactor(android): tell development installs apart from github ones Platform: android --- .../kotlin/com/exptech/dpip/DeviceInfoChannel.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/com/exptech/dpip/DeviceInfoChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/DeviceInfoChannel.kt index c77d8ac8f..18fe43898 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/DeviceInfoChannel.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/DeviceInfoChannel.kt @@ -3,6 +3,7 @@ package com.exptech.dpip import android.annotation.SuppressLint import android.app.ActivityManager import android.content.Context +import android.content.pm.ApplicationInfo import android.os.Build import android.provider.Settings import io.flutter.plugin.common.MethodCall @@ -39,10 +40,16 @@ class DeviceInfoChannel(private val context: Context) : /** * Where this build came from — which decides where an update prompt sends - * the user. A sideloaded APK has no store page to update from, so it is - * reported as such and gets the GitHub release instead. + * the user. A debuggable build (flutter run, Android Studio, adb) is a + * development environment and says so: the flag is part of the build, not + * an installer record, so it holds even though adb leaves no installer. A + * non-store release has no store page to update from and gets the GitHub + * release instead. */ private fun installSource(): String { + if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) { + return "development" + } val installer = try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { context.packageManager @@ -54,10 +61,10 @@ class DeviceInfoChannel(private val context: Context) : } } catch (e: Exception) { // The package can be queried out from under us (uninstalling while - // running); an unknown installer is a sideload for our purposes. + // running); an unknown installer means no store did it. null } - return if (installer == "com.android.vending") "playStore" else "sideload" + return if (installer == "com.android.vending") "playStore" else "github" } /** Total physical RAM in MiB — the cheap proxy for the low-end tier. */ From 4ffeba753dd85207a22800dd4bb8b1dd95d04d45 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 24 Aug 2026 12:48:45 +0800 Subject: [PATCH 28/40] chore(diagnostics): load the install source during bootstrap --- lib/bootstrap.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 20fa906b9..866712382 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -14,6 +14,7 @@ import 'package:dpip/core/version/app_build.dart'; import 'package:dpip/core/logging/log_store.dart'; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/platform/background_location.dart'; +import 'package:dpip/core/platform/install_source.dart'; import 'package:dpip/core/network/dio_client.dart'; import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; @@ -162,6 +163,18 @@ Future bootstrap() async { Log.info('DPIP starting up'); _refuseUnlessLaunchedByTool(); + // Which distributor this build came from (App Store, Play Store, TestFlight, + // or a non-store install — a GitHub release APK included). + // `InstallSourceService` + // memoizes and logs on its own first call, so firing it here just moves that + // line to the top of every session's log instead of leaving it to arrive + // whenever `UpdatePrompt` gets around to its own post-first-frame check — + // a bug report's most basic question ("did this even come from a store?") + // otherwise depended on that check having run and logged before the report + // was pulled. Unawaited: a platform channel round trip must never delay + // launch, and nothing here consumes the result. + unawaited(InstallSourceService.load()); + // The bundled weather glyphs are Material Symbols (Apache-2.0). Registering // the licence puts it in the app's own 開放原始碼授權 page (More → licences), // which is where a bundled third-party asset has to be declared — Flutter From 2c0bb72145073f0f8d5811ce74ab84b16af086fb Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 24 Aug 2026 15:26:01 +0800 Subject: [PATCH 29/40] feat(location): add search to the region picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 常用地區的新增地區頁面可以搜尋縣市與鄉鎮 New(en-US): the region picker gained search for counties, cities, and townships --- .../presentation/pages/region_city_page.dart | 108 ++++++++++++---- .../pages/region_select_page.dart | 117 +++++++++++++----- lib/l10n/app_en.arb | 8 ++ lib/l10n/app_fil.arb | 8 ++ lib/l10n/app_id.arb | 8 ++ lib/l10n/app_ja.arb | 8 ++ lib/l10n/app_ko.arb | 8 ++ lib/l10n/app_th.arb | 8 ++ lib/l10n/app_vi.arb | 8 ++ lib/l10n/app_yue.arb | 8 ++ lib/l10n/app_zh.arb | 8 ++ lib/l10n/app_zh_Hans.arb | 8 ++ lib/l10n/app_zh_Hant_HK.arb | 8 ++ lib/l10n/app_zh_TW.arb | 8 ++ lib/l10n/gen/app_localizations.dart | 48 +++++++ lib/l10n/gen/app_localizations_en.dart | 24 ++++ lib/l10n/gen/app_localizations_fil.dart | 24 ++++ lib/l10n/gen/app_localizations_id.dart | 24 ++++ lib/l10n/gen/app_localizations_ja.dart | 24 ++++ lib/l10n/gen/app_localizations_ko.dart | 24 ++++ lib/l10n/gen/app_localizations_th.dart | 24 ++++ lib/l10n/gen/app_localizations_vi.dart | 24 ++++ lib/l10n/gen/app_localizations_yue.dart | 24 ++++ lib/l10n/gen/app_localizations_zh.dart | 96 ++++++++++++++ 24 files changed, 600 insertions(+), 57 deletions(-) diff --git a/lib/features/location/presentation/pages/region_city_page.dart b/lib/features/location/presentation/pages/region_city_page.dart index 2cd2af983..b4bda67e8 100644 --- a/lib/features/location/presentation/pages/region_city_page.dart +++ b/lib/features/location/presentation/pages/region_city_page.dart @@ -2,22 +2,25 @@ /// toggleable as a saved Home region (up to [RegionStore.maxSaved]). library; +import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/geo/town.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -/// Lists the townships of [city]. Tapping a row toggles it as a saved region: -/// a saved row shows a filled star and removes on tap; an unsaved row adds -/// (when under the cap) or, once full, leaves the selection unchanged and -/// explains the limit. The selection is stored by **code** in the [RegionStore]; -/// names and coordinates shown here are derived from the directory. -class RegionCityPage extends StatelessWidget { +/// Lists the townships of [city], filterable from a search field on top. +/// Tapping a row toggles it as a saved region: a saved row shows a filled star +/// and removes on tap; an unsaved row adds (when under the cap) or, once full, +/// leaves the selection unchanged and explains the limit. The selection is +/// stored by **code** in the [RegionStore]; names and coordinates shown here +/// are derived from the directory. +class RegionCityPage extends StatefulWidget { const RegionCityPage({ super.key, required this.city, @@ -34,39 +37,94 @@ class RegionCityPage extends StatelessWidget { /// 是否成功選擇後返回頁面 final bool? returnToMore; + @override + State createState() => _RegionCityPageState(); +} + +class _RegionCityPageState extends State { + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final directory = context.read(); final store = context.watch(); - final towns = directory.townsInCity(city); + final towns = directory.townsInCity(widget.city); final query = GoRouterState.of(context).uri.queryParameters; - final effectiveReplaceCode = replaceCode ?? query['replace']; + final effectiveReplaceCode = widget.replaceCode ?? query['replace']; + + final needle = _searchController.text.trim().toLowerCase(); + final shown = [ + for (final town in towns) + if (needle.isEmpty || town.townName.toLowerCase().contains(needle)) + town, + ]; return Scaffold( - appBar: AppBar(title: Text(city)), + appBar: AppBar(title: Text(widget.city)), body: ListView( children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + child: TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: l10n.regionSearchTownHint, + prefixIcon: const Icon(Icons.search), + suffixIcon: needle.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + tooltip: l10n.commonClose, + onPressed: () { + _searchController.clear(); + setState(() {}); + }, + ), + isDense: true, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + ), SectionHeader( l10n.regionSelectCount( store.savedCodes.length, RegionStore.maxSaved, ), ), - for (final town in towns) - _TownTile( - town: town, - saved: store.savedCodes.contains(town.code), - enabled: - effectiveReplaceCode == null || - town.code == effectiveReplaceCode || - !store.savedCodes.contains(town.code), - canAdd: - effectiveReplaceCode != null || - store.canSave(town.code) || - store.savedCodes.contains(town.code), - onToggle: () => _toggle(context, store, town), - ), + if (shown.isEmpty) + EmptyView( + icon: Icons.search_off, + message: l10n.regionSearchTownEmpty, + ) + else + for (final town in shown) + _TownTile( + town: town, + saved: store.savedCodes.contains(town.code), + enabled: + effectiveReplaceCode == null || + town.code == effectiveReplaceCode || + !store.savedCodes.contains(town.code), + canAdd: + effectiveReplaceCode != null || + store.canSave(town.code) || + store.savedCodes.contains(town.code), + onToggle: () => _toggle(context, store, town), + ), ], ), ); @@ -74,9 +132,9 @@ class RegionCityPage extends StatelessWidget { void _toggle(BuildContext context, RegionStore store, Town town) { final query = GoRouterState.of(context).uri.queryParameters; - final replace = replaceCode ?? query['replace']; + final replace = widget.replaceCode ?? query['replace']; final shouldReturnToMore = - returnToMore == true || query['returnToMore'] == '1'; + widget.returnToMore == true || query['returnToMore'] == '1'; var changed = false; if (replace != null) { if (store.savedCodes.contains(town.code) && town.code != replace) return; diff --git a/lib/features/location/presentation/pages/region_select_page.dart b/lib/features/location/presentation/pages/region_select_page.dart index 223aca885..aa5991a0b 100644 --- a/lib/features/location/presentation/pages/region_select_page.dart +++ b/lib/features/location/presentation/pages/region_select_page.dart @@ -1,20 +1,25 @@ -/// The first level of the region picker: a list of cities to drill into. +/// The first level of the region picker: a search field over the counties and +/// cities, then the full city list to drill into. library; +import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -/// Lists every city (`縣市`); tapping one opens its township list where regions -/// are toggled on/off. A city that already holds a saved township is marked with -/// a star, and the header shows how many of the [RegionStore.maxSaved] slots are -/// used — so the whole selection is legible from the top level. -class RegionSelectPage extends StatelessWidget { +/// The city level of the region picker, with a filter on top. +/// +/// The query narrows the city list in place — it never leaves this page: a +/// matching city keeps its star marker and drill-down, a non-matching one +/// disappears, and no match at all shows an empty view. Townships are reached +/// by drilling in, as before. +class RegionSelectPage extends StatefulWidget { const RegionSelectPage({super.key, this.replaceCode, this.returnToMore}); /// 選擇一個區域會替換掉之前的 @@ -23,6 +28,19 @@ class RegionSelectPage extends StatelessWidget { /// 是否成功選擇後返回頁面 final bool? returnToMore; + @override + State createState() => _RegionSelectPageState(); +} + +class _RegionSelectPageState extends State { + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -30,49 +48,84 @@ class RegionSelectPage extends StatelessWidget { final directory = context.read(); final store = context.watch(); final query = GoRouterState.of(context).uri.queryParameters; - final effectiveReplaceCode = replaceCode ?? query['replace']; + final effectiveReplaceCode = widget.replaceCode ?? query['replace']; final effectiveReturnToMore = - returnToMore == true || query['returnToMore'] == '1'; + widget.returnToMore == true || query['returnToMore'] == '1'; - final cities = directory.cities; - // Cities that contain at least one saved township, for the star marker. final savedCities = { for (final code in store.savedCodes) directory.byCode(code)?.cityName, }; + final needle = _searchController.text.trim().toLowerCase(); + final cities = [ + for (final city in directory.cities) + if (needle.isEmpty || city.toLowerCase().contains(needle)) city, + ]; return Scaffold( appBar: AppBar(title: Text(l10n.regionSelectTitle)), body: ListView( children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + child: TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: l10n.regionSearchHint, + prefixIcon: const Icon(Icons.search), + suffixIcon: needle.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + tooltip: l10n.commonClose, + onPressed: () { + _searchController.clear(); + setState(() {}); + }, + ), + isDense: true, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + ), SectionHeader( l10n.regionSelectCount( store.savedCodes.length, RegionStore.maxSaved, ), ), - for (final city in cities) - ListTile( - leading: const Icon(Icons.location_city_outlined), - title: Text(city), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (savedCities.contains(city)) - Icon(Icons.star, size: 18, color: colors.primary), - const Icon(Icons.chevron_right), - ], - ), - onTap: () => context.pushNamed( - AppRoutes.regionSelectCity, - pathParameters: {'city': city}, - queryParameters: { - ...?(effectiveReplaceCode == null - ? null - : {'replace': effectiveReplaceCode}), - if (effectiveReturnToMore) 'returnToMore': '1', - }, + if (cities.isEmpty) + EmptyView(icon: Icons.search_off, message: l10n.regionSearchEmpty) + else + for (final city in cities) + ListTile( + leading: const Icon(Icons.location_city_outlined), + title: Text(city), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (savedCities.contains(city)) + Icon(Icons.star, size: 18, color: colors.primary), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => context.pushNamed( + AppRoutes.regionSelectCity, + pathParameters: {'city': city}, + queryParameters: { + ...?(effectiveReplaceCode == null + ? null + : {'replace': effectiveReplaceCode}), + if (effectiveReturnToMore) 'returnToMore': '1', + }, + ), ), - ), ], ), ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 224c7c35a..bc5e184f9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1027,6 +1027,10 @@ "description": "Notify channel title" }, "mapLayerOrderTitle": "Reorder layers", + "mapLayerShow": "Show layer", + "mapLayerHide": "Hide layer", + "mapLayerShowAll": "Show all", + "mapLayerHideAll": "Hide all", "@onboardingPermBackgroundDesc": { "description": "Permission row description: background location" }, @@ -2526,6 +2530,10 @@ }, "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "Add a region", + "regionSearchHint": "Search counties and cities", + "regionSearchEmpty": "No matching counties or cities", + "regionSearchTownHint": "Search townships", + "regionSearchTownEmpty": "No matching townships", "displaySettings": "Display", "restroomGradePoor": "Below standard", "@moreSectionNotify": { diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 4ecbe9a0c..6f21e5c4c 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "Ayusin ang ayos ng layer", + "mapLayerShow": "Ipakita ang layer", + "mapLayerHide": "Itago ang layer", + "mapLayerShowAll": "Ipakita lahat", + "mapLayerHideAll": "Itago lahat", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "Tropical depression TD {no}", "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "Magdagdag ng rehiyon", + "regionSearchHint": "Maghanap ng mga lalawigan at lungsod", + "regionSearchEmpty": "Walang tumugmang lalawigan o lungsod", + "regionSearchTownHint": "Maghanap ng mga bayan", + "regionSearchTownEmpty": "Walang tumugmang bayan", "displaySettings": "Pagpapakita", "restroomGradePoor": "Mas mababa sa pamantayan", "restroomCategoryTourist": "Lugar para sa turista", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 00fc4c152..845190876 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "Urutkan lapisan", + "mapLayerShow": "Tampilkan lapisan", + "mapLayerHide": "Sembunyikan lapisan", + "mapLayerShowAll": "Tampilkan semua", + "mapLayerHideAll": "Sembunyikan semua", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "Depresi tropis TD {no}", "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "Tambah wilayah", + "regionSearchHint": "Cari kabupaten dan kota", + "regionSearchEmpty": "Tidak ada kabupaten/kota yang cocok", + "regionSearchTownHint": "Cari kecamatan", + "regionSearchTownEmpty": "Tidak ada kecamatan yang cocok", "displaySettings": "Tampilan", "restroomGradePoor": "Di bawah standar", "restroomCategoryTourist": "Kawasan wisata", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 79a253e5c..81fbc3ba8 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "レイヤーの順番", + "mapLayerShow": "レイヤーを表示", + "mapLayerHide": "レイヤーを非表示", + "mapLayerShowAll": "すべて表示", + "mapLayerHideAll": "すべて非表示", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "熱帯低気圧 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水蒸気", "regionAddButton": "地域を追加", + "regionSearchHint": "都道府県・市区を検索", + "regionSearchEmpty": "一致する地域がありません", + "regionSearchTownHint": "町村を検索", + "regionSearchTownEmpty": "該当する町村がありません", "displaySettings": "表示", "restroomGradePoor": "不合格", "restroomCategoryTourist": "観光地・景勝地", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 20b94c07b..1510a0b4a 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "레이어 순서", + "mapLayerShow": "레이어 표시", + "mapLayerHide": "레이어 숨기기", + "mapLayerShowAll": "전체 표시", + "mapLayerHideAll": "전체 숨기기", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "열대 저기압 TD {no}", "mapLayerSatelliteWatervapor": "히마와리 수증기", "regionAddButton": "지역 추가", + "regionSearchHint": "시·도 검색", + "regionSearchEmpty": "일치하는 시·도가 없습니다", + "regionSearchTownHint": "읍·면·동 검색", + "regionSearchTownEmpty": "일치하는 읍·면·동이 없습니다", "displaySettings": "화면", "restroomGradePoor": "불합격", "restroomCategoryTourist": "관광 지역·경치 구역", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 750418f88..d9f8632ca 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "จัดเรียงเลเยอร์", + "mapLayerShow": "แสดงเลเยอร์", + "mapLayerHide": "ซ่อนเลเยอร์", + "mapLayerShowAll": "แสดงทั้งหมด", + "mapLayerHideAll": "ซ่อนทั้งหมด", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "ดีเปรสชันเขตร้อน TD {no}", "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "เพิ่มพื้นที่", + "regionSearchHint": "ค้นหาจังหวัดและเมือง", + "regionSearchEmpty": "ไม่พบจังหวัดหรือเมืองที่ตรงกัน", + "regionSearchTownHint": "ค้นหาตำบล", + "regionSearchTownEmpty": "ไม่พบตำบลที่ตรงกัน", "displaySettings": "การแสดงผล", "restroomGradePoor": "ต่ำกว่ามาตรฐาน", "restroomCategoryTourist": "แหล่งท่องเที่ยว", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index da08005f9..a918c954e 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "Sắp xếp thứ tự lớp", + "mapLayerShow": "Hiện lớp bản đồ", + "mapLayerHide": "Ẩn lớp bản đồ", + "mapLayerShowAll": "Hiện tất cả", + "mapLayerHideAll": "Ẩn tất cả", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "Áp thấp nhiệt đới TD {no}", "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "Thêm khu vực", + "regionSearchHint": "Tìm kiếm tỉnh và thành phố", + "regionSearchEmpty": "Không tìm thấy tỉnh/thành phố phù hợp", + "regionSearchTownHint": "Tìm kiếm xã", + "regionSearchTownEmpty": "Không tìm thấy xã phù hợp", "displaySettings": "Hiển thị", "restroomGradePoor": "Dưới chuẩn", "restroomCategoryTourist": "Khu du lịch thắng cảnh", diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 1c068f20b..cea976906 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -361,6 +361,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "調整圖層順序", + "mapLayerShow": "顯示圖層", + "mapLayerHide": "隱藏圖層", + "mapLayerShowAll": "全部顯示", + "mapLayerHideAll": "全部隱藏", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "熱帶性低氣壓 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水氣", "regionAddButton": "新增地區", + "regionSearchHint": "搜尋縣市", + "regionSearchEmpty": "搵唔到符合嘅縣市", + "regionSearchTownHint": "搜尋鄉鎮", + "regionSearchTownEmpty": "搵唔到符合嘅鄉鎮", "displaySettings": "顯示設定", "restroomGradePoor": "唔合格", "restroomCategoryTourist": "觀光地區及風景區", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 13624956e..a2914c446 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "調整圖層順序", + "mapLayerShow": "顯示圖層", + "mapLayerHide": "隱藏圖層", + "mapLayerShowAll": "全部顯示", + "mapLayerHideAll": "全部隱藏", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "熱帶性低氣壓 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水氣", "regionAddButton": "新增地區", + "regionSearchHint": "搜尋縣市", + "regionSearchEmpty": "找不到符合的縣市", + "regionSearchTownHint": "搜尋鄉鎮市區", + "regionSearchTownEmpty": "找不到符合的鄉鎮市區", "displaySettings": "顯示設定", "restroomGradePoor": "不合格", "restroomCategoryTourist": "觀光地區及風景區", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 468da3694..0adafe4a4 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "调整图层顺序", + "mapLayerShow": "显示图层", + "mapLayerHide": "隐藏图层", + "mapLayerShowAll": "全部显示", + "mapLayerHideAll": "全部隐藏", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "热带性低气压 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水气", "regionAddButton": "添加地区", + "regionSearchHint": "搜索县市", + "regionSearchEmpty": "找不到符合的县市", + "regionSearchTownHint": "搜索乡镇市区", + "regionSearchTownEmpty": "找不到符合的乡镇市区", "displaySettings": "显示设置", "restroomGradePoor": "不合格", "restroomCategoryTourist": "观光地区及风景区", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 35c7e2697..147925713 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "調整圖層順序", + "mapLayerShow": "顯示圖層", + "mapLayerHide": "隱藏圖層", + "mapLayerShowAll": "全部顯示", + "mapLayerHideAll": "全部隱藏", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "熱帶性低氣壓 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水氣", "regionAddButton": "新增地區", + "regionSearchHint": "搜尋縣市", + "regionSearchEmpty": "搵唔到符合嘅縣市", + "regionSearchTownHint": "搜尋鄉鎮", + "regionSearchTownEmpty": "搵唔到符合嘅鄉鎮", "displaySettings": "顯示設定", "restroomGradePoor": "不合格", "restroomCategoryTourist": "觀光地區及風景區", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 72db6601c..5fc562bd7 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -359,6 +359,10 @@ "description": "Send message button" }, "mapLayerOrderTitle": "調整圖層順序", + "mapLayerShow": "顯示圖層", + "mapLayerHide": "隱藏圖層", + "mapLayerShowAll": "全部顯示", + "mapLayerHideAll": "全部隱藏", "@skyTimeNoon": { "description": "Label for the skyTimeNoon option in the experimental backdrop settings." }, @@ -921,6 +925,10 @@ "typhoonPickerTd": "熱帶性低氣壓 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水氣", "regionAddButton": "新增地區", + "regionSearchHint": "搜尋縣市", + "regionSearchEmpty": "找不到符合的縣市", + "regionSearchTownHint": "搜尋鄉鎮市區", + "regionSearchTownEmpty": "找不到符合的鄉鎮市區", "displaySettings": "顯示設定", "restroomGradePoor": "不合格", "restroomCategoryTourist": "觀光地區及風景區", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index e7a69845a..9088c8bfa 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1547,6 +1547,30 @@ abstract class AppLocalizations { /// **'Reorder layers'** String get mapLayerOrderTitle; + /// No description provided for @mapLayerShow. + /// + /// In en, this message translates to: + /// **'Show layer'** + String get mapLayerShow; + + /// No description provided for @mapLayerHide. + /// + /// In en, this message translates to: + /// **'Hide layer'** + String get mapLayerHide; + + /// No description provided for @mapLayerShowAll. + /// + /// In en, this message translates to: + /// **'Show all'** + String get mapLayerShowAll; + + /// No description provided for @mapLayerHideAll. + /// + /// In en, this message translates to: + /// **'Hide all'** + String get mapLayerHideAll; + /// Affirmative value in the disaster-map detail sheet /// /// In en, this message translates to: @@ -3557,6 +3581,30 @@ abstract class AppLocalizations { /// **'Add a region'** String get regionAddButton; + /// No description provided for @regionSearchHint. + /// + /// In en, this message translates to: + /// **'Search counties and cities'** + String get regionSearchHint; + + /// No description provided for @regionSearchEmpty. + /// + /// In en, this message translates to: + /// **'No matching counties or cities'** + String get regionSearchEmpty; + + /// No description provided for @regionSearchTownHint. + /// + /// In en, this message translates to: + /// **'Search townships'** + String get regionSearchTownHint; + + /// No description provided for @regionSearchTownEmpty. + /// + /// In en, this message translates to: + /// **'No matching townships'** + String get regionSearchTownEmpty; + /// Display-settings menu entry and page title (theme mode) /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index feead8756..b361b12b1 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -782,6 +782,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapLayerOrderTitle => 'Reorder layers'; + @override + String get mapLayerShow => 'Show layer'; + + @override + String get mapLayerHide => 'Hide layer'; + + @override + String get mapLayerShowAll => 'Show all'; + + @override + String get mapLayerHideAll => 'Hide all'; + @override String get dpmYes => 'Yes'; @@ -1863,6 +1875,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get regionAddButton => 'Add a region'; + @override + String get regionSearchHint => 'Search counties and cities'; + + @override + String get regionSearchEmpty => 'No matching counties or cities'; + + @override + String get regionSearchTownHint => 'Search townships'; + + @override + String get regionSearchTownEmpty => 'No matching townships'; + @override String get displaySettings => 'Display'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 567744487..1c66146e0 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -787,6 +787,18 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapLayerOrderTitle => 'Ayusin ang ayos ng layer'; + @override + String get mapLayerShow => 'Ipakita ang layer'; + + @override + String get mapLayerHide => 'Itago ang layer'; + + @override + String get mapLayerShowAll => 'Ipakita lahat'; + + @override + String get mapLayerHideAll => 'Itago lahat'; + @override String get dpmYes => 'Oo'; @@ -1873,6 +1885,18 @@ class AppLocalizationsFil extends AppLocalizations { @override String get regionAddButton => 'Magdagdag ng rehiyon'; + @override + String get regionSearchHint => 'Maghanap ng mga lalawigan at lungsod'; + + @override + String get regionSearchEmpty => 'Walang tumugmang lalawigan o lungsod'; + + @override + String get regionSearchTownHint => 'Maghanap ng mga bayan'; + + @override + String get regionSearchTownEmpty => 'Walang tumugmang bayan'; + @override String get displaySettings => 'Pagpapakita'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 3c4617076..15a38f4b6 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -784,6 +784,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapLayerOrderTitle => 'Urutkan lapisan'; + @override + String get mapLayerShow => 'Tampilkan lapisan'; + + @override + String get mapLayerHide => 'Sembunyikan lapisan'; + + @override + String get mapLayerShowAll => 'Tampilkan semua'; + + @override + String get mapLayerHideAll => 'Sembunyikan semua'; + @override String get dpmYes => 'Ya'; @@ -1867,6 +1879,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get regionAddButton => 'Tambah wilayah'; + @override + String get regionSearchHint => 'Cari kabupaten dan kota'; + + @override + String get regionSearchEmpty => 'Tidak ada kabupaten/kota yang cocok'; + + @override + String get regionSearchTownHint => 'Cari kecamatan'; + + @override + String get regionSearchTownEmpty => 'Tidak ada kecamatan yang cocok'; + @override String get displaySettings => 'Tampilan'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 047a34937..19544a6a9 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -769,6 +769,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapLayerOrderTitle => 'レイヤーの順番'; + @override + String get mapLayerShow => 'レイヤーを表示'; + + @override + String get mapLayerHide => 'レイヤーを非表示'; + + @override + String get mapLayerShowAll => 'すべて表示'; + + @override + String get mapLayerHideAll => 'すべて非表示'; + @override String get dpmYes => 'はい'; @@ -1831,6 +1843,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get regionAddButton => '地域を追加'; + @override + String get regionSearchHint => '都道府県・市区を検索'; + + @override + String get regionSearchEmpty => '一致する地域がありません'; + + @override + String get regionSearchTownHint => '町村を検索'; + + @override + String get regionSearchTownEmpty => '該当する町村がありません'; + @override String get displaySettings => '表示'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index de58f35a3..55621f74c 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -768,6 +768,18 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapLayerOrderTitle => '레이어 순서'; + @override + String get mapLayerShow => '레이어 표시'; + + @override + String get mapLayerHide => '레이어 숨기기'; + + @override + String get mapLayerShowAll => '전체 표시'; + + @override + String get mapLayerHideAll => '전체 숨기기'; + @override String get dpmYes => '예'; @@ -1831,6 +1843,18 @@ class AppLocalizationsKo extends AppLocalizations { @override String get regionAddButton => '지역 추가'; + @override + String get regionSearchHint => '시·도 검색'; + + @override + String get regionSearchEmpty => '일치하는 시·도가 없습니다'; + + @override + String get regionSearchTownHint => '읍·면·동 검색'; + + @override + String get regionSearchTownEmpty => '일치하는 읍·면·동이 없습니다'; + @override String get displaySettings => '화면'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 6b46432df..9c148e0dc 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -780,6 +780,18 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapLayerOrderTitle => 'จัดเรียงเลเยอร์'; + @override + String get mapLayerShow => 'แสดงเลเยอร์'; + + @override + String get mapLayerHide => 'ซ่อนเลเยอร์'; + + @override + String get mapLayerShowAll => 'แสดงทั้งหมด'; + + @override + String get mapLayerHideAll => 'ซ่อนทั้งหมด'; + @override String get dpmYes => 'ใช่'; @@ -1859,6 +1871,18 @@ class AppLocalizationsTh extends AppLocalizations { @override String get regionAddButton => 'เพิ่มพื้นที่'; + @override + String get regionSearchHint => 'ค้นหาจังหวัดและเมือง'; + + @override + String get regionSearchEmpty => 'ไม่พบจังหวัดหรือเมืองที่ตรงกัน'; + + @override + String get regionSearchTownHint => 'ค้นหาตำบล'; + + @override + String get regionSearchTownEmpty => 'ไม่พบตำบลที่ตรงกัน'; + @override String get displaySettings => 'การแสดงผล'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 5987676de..057a81b02 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -781,6 +781,18 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapLayerOrderTitle => 'Sắp xếp thứ tự lớp'; + @override + String get mapLayerShow => 'Hiện lớp bản đồ'; + + @override + String get mapLayerHide => 'Ẩn lớp bản đồ'; + + @override + String get mapLayerShowAll => 'Hiện tất cả'; + + @override + String get mapLayerHideAll => 'Ẩn tất cả'; + @override String get dpmYes => 'Có'; @@ -1863,6 +1875,18 @@ class AppLocalizationsVi extends AppLocalizations { @override String get regionAddButton => 'Thêm khu vực'; + @override + String get regionSearchHint => 'Tìm kiếm tỉnh và thành phố'; + + @override + String get regionSearchEmpty => 'Không tìm thấy tỉnh/thành phố phù hợp'; + + @override + String get regionSearchTownHint => 'Tìm kiếm xã'; + + @override + String get regionSearchTownEmpty => 'Không tìm thấy xã phù hợp'; + @override String get displaySettings => 'Hiển thị'; diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 3edcd5f30..e158ee75b 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -764,6 +764,18 @@ class AppLocalizationsYue extends AppLocalizations { @override String get mapLayerOrderTitle => '調整圖層順序'; + @override + String get mapLayerShow => '顯示圖層'; + + @override + String get mapLayerHide => '隱藏圖層'; + + @override + String get mapLayerShowAll => '全部顯示'; + + @override + String get mapLayerHideAll => '全部隱藏'; + @override String get dpmYes => '係'; @@ -1820,6 +1832,18 @@ class AppLocalizationsYue extends AppLocalizations { @override String get regionAddButton => '新增地區'; + @override + String get regionSearchHint => '搜尋縣市'; + + @override + String get regionSearchEmpty => '搵唔到符合嘅縣市'; + + @override + String get regionSearchTownHint => '搜尋鄉鎮'; + + @override + String get regionSearchTownEmpty => '搵唔到符合嘅鄉鎮'; + @override String get displaySettings => '顯示設定'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index df109c2c8..b1ea4ef81 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -764,6 +764,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapLayerOrderTitle => '調整圖層順序'; + @override + String get mapLayerShow => '顯示圖層'; + + @override + String get mapLayerHide => '隱藏圖層'; + + @override + String get mapLayerShowAll => '全部顯示'; + + @override + String get mapLayerHideAll => '全部隱藏'; + @override String get dpmYes => '是'; @@ -1820,6 +1832,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get regionAddButton => '新增地區'; + @override + String get regionSearchHint => '搜尋縣市'; + + @override + String get regionSearchEmpty => '找不到符合的縣市'; + + @override + String get regionSearchTownHint => '搜尋鄉鎮市區'; + + @override + String get regionSearchTownEmpty => '找不到符合的鄉鎮市區'; + @override String get displaySettings => '顯示設定'; @@ -3896,6 +3920,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapLayerOrderTitle => '调整图层顺序'; + @override + String get mapLayerShow => '显示图层'; + + @override + String get mapLayerHide => '隐藏图层'; + + @override + String get mapLayerShowAll => '全部显示'; + + @override + String get mapLayerHideAll => '全部隐藏'; + @override String get dpmYes => '是'; @@ -4952,6 +4988,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get regionAddButton => '添加地区'; + @override + String get regionSearchHint => '搜索县市'; + + @override + String get regionSearchEmpty => '找不到符合的县市'; + + @override + String get regionSearchTownHint => '搜索乡镇市区'; + + @override + String get regionSearchTownEmpty => '找不到符合的乡镇市区'; + @override String get displaySettings => '显示设置'; @@ -7028,6 +7076,18 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapLayerOrderTitle => '調整圖層順序'; + @override + String get mapLayerShow => '顯示圖層'; + + @override + String get mapLayerHide => '隱藏圖層'; + + @override + String get mapLayerShowAll => '全部顯示'; + + @override + String get mapLayerHideAll => '全部隱藏'; + @override String get dpmYes => '是'; @@ -8084,6 +8144,18 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get regionAddButton => '新增地區'; + @override + String get regionSearchHint => '搜尋縣市'; + + @override + String get regionSearchEmpty => '搵唔到符合嘅縣市'; + + @override + String get regionSearchTownHint => '搜尋鄉鎮'; + + @override + String get regionSearchTownEmpty => '搵唔到符合嘅鄉鎮'; + @override String get displaySettings => '顯示設定'; @@ -10160,6 +10232,18 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapLayerOrderTitle => '調整圖層順序'; + @override + String get mapLayerShow => '顯示圖層'; + + @override + String get mapLayerHide => '隱藏圖層'; + + @override + String get mapLayerShowAll => '全部顯示'; + + @override + String get mapLayerHideAll => '全部隱藏'; + @override String get dpmYes => '是'; @@ -11216,6 +11300,18 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get regionAddButton => '新增地區'; + @override + String get regionSearchHint => '搜尋縣市'; + + @override + String get regionSearchEmpty => '找不到符合的縣市'; + + @override + String get regionSearchTownHint => '搜尋鄉鎮市區'; + + @override + String get regionSearchTownEmpty => '找不到符合的鄉鎮市區'; + @override String get displaySettings => '顯示設定'; From c3023813f74fba07421b702226a03b72888bf390 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 24 Aug 2026 15:32:24 +0800 Subject: [PATCH 30/40] feat(map): let layers be hidden from the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 地圖圖層可以隱藏,不需要的圖層不再出現在選單 New(en-US): map layers can be hidden from the picker --- lib/bootstrap.dart | 3 + lib/core/di/core_providers.dart | 4 + lib/core/di/shared_deps.dart | 5 + .../map_layer_visibility_controller.dart | 72 +++++ lib/core/settings/setting_keys.dart | 5 + .../map/presentation/pages/map_page.dart | 25 +- lib/shared/map/map_layer_switcher.dart | 293 +++++++++++++++--- lib/shared/map/map_scaffold.dart | 28 ++ .../map_layer_visibility_controller_test.dart | 71 +++++ test/shared/map/map_layer_switcher_test.dart | 235 +++++++++++++- 10 files changed, 683 insertions(+), 58 deletions(-) create mode 100644 lib/core/settings/map_layer_visibility_controller.dart create mode 100644 test/core/settings/map_layer_visibility_controller_test.dart diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 866712382..86378df21 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -45,6 +45,7 @@ import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/locale_controller.dart'; import 'package:dpip/core/settings/map_layer_order_controller.dart'; +import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/core/astro/tle_store.dart'; import 'package:dpip/core/settings/setting_keys.dart'; @@ -223,6 +224,7 @@ Future bootstrap() async { final display = DisplaySettings(settings); final defaultMapLayer = DefaultMapLayerController(settings); final mapLayerOrder = MapLayerOrderController(settings); + final mapLayerVisibility = MapLayerVisibilityController(settings); final cache = await cacheFuture; final dio = createDio(etagCache: cache?.etag, usage: cache?.usage); final endpointHealth = EndpointHealthMonitor(); @@ -374,6 +376,7 @@ Future bootstrap() async { display: display, defaultMapLayer: defaultMapLayer, mapLayerOrder: mapLayerOrder, + mapLayerVisibility: mapLayerVisibility, meshtastic: meshtastic, meshLink: meshLink, meshAlerts: meshAlerts, diff --git a/lib/core/di/core_providers.dart b/lib/core/di/core_providers.dart index 2957707d4..1c57522a0 100644 --- a/lib/core/di/core_providers.dart +++ b/lib/core/di/core_providers.dart @@ -28,6 +28,7 @@ import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/locale_controller.dart'; import 'package:dpip/core/settings/map_layer_order_controller.dart'; +import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/color_vision_controller.dart'; @@ -56,6 +57,9 @@ List coreProviders(SharedDeps deps) => [ ChangeNotifierProvider.value( value: deps.mapLayerOrder, ), + ChangeNotifierProvider.value( + value: deps.mapLayerVisibility, + ), Provider.value(value: deps.settings), Provider.value(value: deps.database), Provider.value(value: deps.tleStore), diff --git a/lib/core/di/shared_deps.dart b/lib/core/di/shared_deps.dart index b0671febb..b27fb2e3b 100644 --- a/lib/core/di/shared_deps.dart +++ b/lib/core/di/shared_deps.dart @@ -26,6 +26,7 @@ import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/locale_controller.dart'; import 'package:dpip/core/settings/map_layer_order_controller.dart'; +import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/core/settings/region_store.dart'; @@ -68,6 +69,7 @@ class SharedDeps { required this.display, required this.defaultMapLayer, required this.mapLayerOrder, + required this.mapLayerVisibility, required this.meshtastic, required this.meshLink, required this.meshAlerts, @@ -153,6 +155,9 @@ class SharedDeps { /// User-customised map layer-picker order (also provided). final MapLayerOrderController mapLayerOrder; + /// The map layers the user hid (also provided). + final MapLayerVisibilityController mapLayerVisibility; + /// LoRa mesh (Meshtastic) over BLE — off-grid emergency messaging. final MeshtasticService meshtastic; diff --git a/lib/core/settings/map_layer_visibility_controller.dart b/lib/core/settings/map_layer_visibility_controller.dart new file mode 100644 index 000000000..d0550c7ce --- /dev/null +++ b/lib/core/settings/map_layer_visibility_controller.dart @@ -0,0 +1,72 @@ +/// Persisted hidden map layers — the per-layer display switch. +library; + +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:flutter/foundation.dart'; + +/// Holds the ids of the map layers the user hid, persisted across launches. +/// +/// A hidden layer disappears from every map surface's picker and never +/// renders; the default is an empty set — every layer a surface offers is +/// shown. Toggling lives in the layer-order editor (the tune icon in the +/// picker), which is also where a layer can be shown again. Surfaces resolve +/// the saved ids against their own layer set, so an id saved on one surface +/// but not offered by another is simply ignored there. +/// +/// "Hidden" is the complement of "shown", not a third state: the map shows +/// exactly one overlay at a time, so hiding means "never offer it to me +/// again", not "keep it loaded but invisible". +class MapLayerVisibilityController extends ChangeNotifier { + MapLayerVisibilityController(this._settings) + : _hidden = + (_settings.getStringList(SettingKeys.mapLayerHiddenIds) ?? const []) + .toSet(); + + final SettingsStore _settings; + + Set _hidden; + + /// The hidden layer ids. Unmodifiable view — mutate through [setHidden]. + Set get hiddenIds => Set.unmodifiable(_hidden); + + /// Whether the layer [id] is currently hidden. + bool isHidden(String id) => _hidden.contains(id); + + /// Persists [hidden]'s new state for [id] and notifies watchers (the picker + /// and every open map surface rebuild). No-op — no write, no notification — + /// when the state already matches. + Future setHidden(String id, {required bool hidden}) async { + if (hidden == _hidden.contains(id)) return; + final next = Set.of(_hidden); + hidden ? next.add(id) : next.remove(id); + _hidden = next; + await _settings.setStringList( + SettingKeys.mapLayerHiddenIds, + _hidden.toList(), + ); + notifyListeners(); + } + + /// Persists [hidden]'s new state for every id in [ids] as one write and one + /// notification — the order editor's per-category "show all" / "hide all" + /// buttons use this so toggling several layers at once doesn't re-persist + /// and rebuild once per layer. No-op when none of them change state. + Future setManyHidden( + Iterable ids, { + required bool hidden, + }) async { + final next = Set.of(_hidden); + var changed = false; + for (final id in ids) { + changed |= hidden ? next.add(id) : next.remove(id); + } + if (!changed) return; + _hidden = next; + await _settings.setStringList( + SettingKeys.mapLayerHiddenIds, + _hidden.toList(), + ); + notifyListeners(); + } +} diff --git a/lib/core/settings/setting_keys.dart b/lib/core/settings/setting_keys.dart index 695965744..376c9f30d 100644 --- a/lib/core/settings/setting_keys.dart +++ b/lib/core/settings/setting_keys.dart @@ -93,6 +93,11 @@ abstract final class SettingKeys { static const SettingKey> mapLayerCategoryOrder = SettingKey>._('map.layerCategoryOrder'); + /// Map layer ids the user hid from the picker (empty = every layer shown). + /// See `MapLayerVisibilityController`. + static const SettingKey> mapLayerHiddenIds = + SettingKey>._('map.layerHiddenIds'); + /// Saved Home township codes (ordered list). See `RegionStore`. static const SettingKey> savedRegionCodes = SettingKey>._('home.savedRegionCodes'); diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index 0f623caf4..73868904c 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -6,6 +6,7 @@ import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/settings/default_map_layer.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; +import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; import 'package:dpip/features/disaster_map/domain/disaster_map_repository.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; @@ -51,6 +52,14 @@ import 'package:provider/provider.dart'; /// /// The initial overlay comes from [DefaultMapLayerController]; a [ValueKey] on /// the scaffold remounts when that preference changes so the new default wins. +/// The key is keyed on the *preference*, not on visibility — hiding the +/// currently-open layer must not remount the whole scaffold (that would close +/// any sheet open above it, such as the layer-order editor the hide was just +/// tapped from). A hidden layer drops out of the picker's list entirely — the +/// order editor's eye toggle is the only way to offer it again. A hide that +/// removes the on-screen layer mid-session is handled by [MapScaffold] itself, +/// which watches [MapLayerVisibilityController] directly and falls back in +/// place. class MapPage extends StatefulWidget { const MapPage({super.key}); @@ -110,15 +119,25 @@ class _MapPageState extends State { @override Widget build(BuildContext context) { + final visibility = context.watch(); // In demo mode the monitor is what there is to see — open straight on it. - final initial = kMonitorDemoEnabled + final preferred = kMonitorDemoEnabled ? DefaultMapLayer.monitor : context.watch().layer; + // Open on the preferred layer unless it (and only it) is hidden; hidden + // layers are otherwise offered like any other. + final initial = _layers.firstWhere( + (layer) => layer.id == preferred.id && !visibility.isHidden(layer.id), + orElse: () => _layers.firstWhere( + (layer) => !visibility.isHidden(layer.id), + orElse: () => _layers.first, + ), + ); return MapScaffold( - key: ValueKey(initial.id), + key: ValueKey(preferred.id), layers: _layers, initialLayerId: initial.id, - initialOsmEnabled: initial == DefaultMapLayer.dpm, + initialOsmEnabled: initial.id == DefaultMapLayer.dpm.id, tabIndex: MapPage.tabIndex, ); } diff --git a/lib/shared/map/map_layer_switcher.dart b/lib/shared/map/map_layer_switcher.dart index 0ab0f9fd0..896629a6d 100644 --- a/lib/shared/map/map_layer_switcher.dart +++ b/lib/shared/map/map_layer_switcher.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/settings/map_layer_order_controller.dart'; +import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_layer_category.dart'; @@ -93,6 +94,7 @@ class MapLayerSwitcher extends StatelessWidget { Future _pick(BuildContext context) async { final orderController = context.read(); + final visibility = context.read(); // Owns restoring the sheet's own height when a remembered scroll offset // needs one — see `_RememberedOffsetList`'s doc for why. final sheetController = DraggableScrollableController(); @@ -127,24 +129,34 @@ class MapLayerSwitcher extends StatelessWidget { tooltip: l10n.mapLayerOrderTitle, visualDensity: VisualDensity.compact, onPressed: () => - _editOrder(sheetContext, orderController), + _editOrder(sheetContext, orderController, visibility), ), ), Expanded( // Live-updates when the order editor above changes it, so - // the picker reflects a reorder the moment the editor - // closes back on top of it. + // the picker reflects a reorder — or a hide — the moment + // the editor closes back on top of it. A hidden layer is + // dropped from this list entirely; the eye toggle in the + // order editor is the only way back. child: ListenableBuilder( - listenable: orderController, + listenable: Listenable.merge([ + orderController, + visibility, + ]), builder: (context, _) { - final ordered = orderedLayers( - layers, - orderController.order, - ); + final ordered = + orderedLayers(layers, orderController.order) + .where( + (layer) => !visibility.isHidden(layer.id), + ) + .toList(); + final visibleCategories = { + for (final layer in ordered) categoryOf(layer.id), + }; final categories = orderedCategories( MapLayerCategory.values, orderController.categoryOrder, - ); + ).where(visibleCategories.contains); return _RememberedOffsetList( // Remembers scroll offset across separate openings // of this sheet — picking a layer pops the sheet @@ -189,22 +201,30 @@ class MapLayerSwitcher extends StatelessWidget { }, ); sheetController.dispose(); - if (selected != null && selected.id != active.id) onSelected(selected); + if (selected == null) return; + // Same-id picks are not skipped: the scaffold's own handler knows its + // *current* layer (post-fallback) and ignores true no-ops itself. + onSelected(selected); } /// Opens the layer-order editor over the picker. Reordering persists to - /// [orderController] on every drop, so closing the editor (or the picker) + /// [orderController] on every drop, and the eye toggles persist to + /// [visibility] immediately, so closing the editor (or the picker) /// never discards a change. Future _editOrder( BuildContext sheetContext, MapLayerOrderController orderController, + MapLayerVisibilityController visibility, ) async { await showModalBottomSheet( context: sheetContext, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (_) => - _LayerOrderSheet(layers: layers, controller: orderController), + builder: (_) => _LayerOrderSheet( + layers: layers, + controller: orderController, + visibility: visibility, + ), ); } } @@ -418,10 +438,15 @@ class _LayerTile extends StatelessWidget { /// button clears both saved orders so the list falls back to the declared /// order. class _LayerOrderSheet extends StatefulWidget { - const _LayerOrderSheet({required this.layers, required this.controller}); + const _LayerOrderSheet({ + required this.layers, + required this.controller, + required this.visibility, + }); final List layers; final MapLayerOrderController controller; + final MapLayerVisibilityController visibility; @override State<_LayerOrderSheet> createState() => _LayerOrderSheetState(); @@ -438,6 +463,10 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { /// The category whose layers are being edited; null shows the category list. MapLayerCategory? _editing; + /// Navigation direction of the last level switch — drill-in slides the new + /// list in from the right, going back mirrors it from the left. + bool _drillingIn = true; + /// Layer ids in current block order — what gets persisted. List get _ids => [ for (final block in _blocks) @@ -487,14 +516,45 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { icon: const Icon(Icons.arrow_back), tooltip: MaterialLocalizations.of(context) .backButtonTooltip, - onPressed: () => setState(() => _editing = null), + onPressed: () => setState(() { + _drillingIn = false; + _editing = null; + }), ), right: closeButton, ), Flexible( - child: editing == null - ? _categoryList(context) - : _layerList(context, editingBlock!), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + // The page matching the current editing state is the one + // entering; it slides in from the right on a drill-in and + // from the left on the way back. The outgoing page runs + // the same tween reversed, so it exits toward the side + // the user came from. + final currentKey = ValueKey( + _editing == null + ? 'categories' + : 'layers-${_editing!.name}', + ); + final incoming = child.key == currentKey; + final sign = _drillingIn ? 1.0 : -1.0; + return ClipRect( + child: SlideTransition( + position: Tween( + begin: Offset(incoming ? sign : -sign, 0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: editing == null + ? _categoryList(context) + : _layerList(context, editingBlock!), + ), ), ], ), @@ -505,6 +565,7 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { Widget _categoryList(BuildContext context) { return ReorderableListView.builder( + key: const ValueKey('categories'), buildDefaultDragHandles: false, padding: const EdgeInsets.fromLTRB( AppSpacing.md, @@ -516,37 +577,127 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { onReorderItem: _reorderCategory, itemBuilder: (context, index) { final block = _blocks[index]; - final canOpen = block.ids.length > 1; + // Every category opens: the eye toggles live on level 2, so a + // single-layer category (radar, typhoon, rts) must be drill-in-able + // even though its reorder list holds exactly one row. return _CategoryOrderTile( key: ValueKey('category-${block.category.name}'), category: block.category, index: index, - canOpen: canOpen, - onTap: canOpen - ? () => setState(() => _editing = block.category) - : null, + onTap: () => setState(() { + _drillingIn = true; + _editing = block.category; + }), ); }, ); } Widget _layerList(BuildContext context, _Block block) { - return ReorderableListView.builder( - buildDefaultDragHandles: false, - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.md, - ), - itemCount: block.ids.length, - onReorderItem: (oldIndex, newIndex) => - _reorderLayer(block, oldIndex, newIndex), - itemBuilder: (context, index) { - final id = block.ids[index]; - final layer = widget.layers.firstWhere((layer) => layer.id == id); - return _ReorderTile(key: ValueKey(id), layer: layer, index: index); - }, + final l10n = AppLocalizations.of(context); + final hideIds = _idsToHideAllIn(block); + final showAllDisabled = block.ids.every( + (id) => !widget.visibility.isHidden(id), + ); + final hideAllDisabled = hideIds.every(widget.visibility.isHidden); + return Column( + key: ValueKey('layers-${block.category.name}'), + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.xs, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: showAllDisabled + ? null + : () => _showAllInCategory(block), + icon: const Icon(Icons.visibility, size: 18), + label: Text(l10n.mapLayerShowAll), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: OutlinedButton.icon( + onPressed: hideAllDisabled + ? null + : () => _hideAllInCategory(block), + icon: const Icon(Icons.visibility_off, size: 18), + label: Text(l10n.mapLayerHideAll), + ), + ), + ], + ), + ), + Flexible( + child: ReorderableListView.builder( + key: ValueKey('layers-list-${block.category.name}'), + buildDefaultDragHandles: false, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + itemCount: block.ids.length, + onReorderItem: (oldIndex, newIndex) => + _reorderLayer(block, oldIndex, newIndex), + itemBuilder: (context, index) { + final id = block.ids[index]; + final layer = widget.layers.firstWhere((layer) => layer.id == id); + return _ReorderTile( + key: ValueKey(id), + layer: layer, + index: index, + hidden: widget.visibility.isHidden(id), + // Hiding must never leave the surface with nothing to show, + // so the last visible layer's eye is disabled until another + // one is shown again. + canHide: + widget.visibility.isHidden(id) || + widget.layers + .where((l) => !widget.visibility.isHidden(l.id)) + .length > + 1, + onToggleVisibility: () => _toggleVisibility(layer), + ); + }, + ), + ), + ], + ); + } + + /// The ids in [block] that "hide all" would actually hide — every id in it, + /// unless nothing outside this category is visible, in which case the + /// first id stays exempt so the surface always has something to show. + List _idsToHideAllIn(_Block block) { + final elsewhereVisible = widget.layers.any( + (layer) => + categoryOf(layer.id) != block.category && + !widget.visibility.isHidden(layer.id), + ); + return elsewhereVisible ? block.ids : block.ids.skip(1).toList(); + } + + /// Shows every layer in [block] as one write — never blocked, since + /// showing more layers can't violate the "always something visible" + /// invariant. + void _showAllInCategory(_Block block) { + setState(() {}); + unawaited(widget.visibility.setManyHidden(block.ids, hidden: false)); + } + + void _hideAllInCategory(_Block block) { + setState(() {}); + unawaited( + widget.visibility.setManyHidden(_idsToHideAllIn(block), hidden: true), ); } @@ -569,6 +720,20 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { unawaited(widget.controller.setOrder(_ids)); } + /// Flips a layer's hidden state. The sheet rebuilds from its own [setState] + /// — it does not listen to the controller — while the write itself is + /// fire-and-forget: every drop/tap supersedes the previous one and the + /// picker underneath reads the controller when it rebuilds. + void _toggleVisibility(MapLayer layer) { + setState(() {}); + unawaited( + widget.visibility.setHidden( + layer.id, + hidden: !widget.visibility.isHidden(layer.id), + ), + ); + } + void _reset() { setState(() { _blocks = _buildBlocks(widget.layers, const [], const []); @@ -626,20 +791,18 @@ List<_Block> _buildBlocks( } /// One row of the level-1 category list. Dragging reorders the categories; -/// tapping a category with more than one layer opens its level-2 layer list. +/// tapping opens the level-2 layer list (order + visibility). class _CategoryOrderTile extends StatelessWidget { const _CategoryOrderTile({ super.key, required this.category, required this.index, - required this.canOpen, required this.onTap, }); final MapLayerCategory category; final int index; - final bool canOpen; - final VoidCallback? onTap; + final VoidCallback onTap; @override Widget build(BuildContext context) { @@ -668,8 +831,7 @@ class _CategoryOrderTile extends StatelessWidget { ), ), ), - if (canOpen) - Icon(Icons.chevron_right, color: colors.onSurfaceVariant), + Icon(Icons.chevron_right, color: colors.onSurfaceVariant), ReorderableDragStartListener( index: index, child: const Padding( @@ -734,18 +896,33 @@ class _CenteredHeader extends StatelessWidget { } } -/// One row of the reorder editor: layer identity on the left, a drag handle on -/// the right. +/// One row of the reorder editor: layer identity on the left, an eye toggle +/// (shown/hidden) and a drag handle on the right. class _ReorderTile extends StatelessWidget { - const _ReorderTile({super.key, required this.layer, required this.index}); + const _ReorderTile({ + super.key, + required this.layer, + required this.index, + required this.hidden, + required this.canHide, + required this.onToggleVisibility, + }); final MapLayer layer; final int index; + /// Whether this layer is currently hidden from the picker. + final bool hidden; + + /// Whether hiding is allowed right now — false for the last visible layer. + final bool canHide; + final VoidCallback onToggleVisibility; + @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.xs), child: Material( @@ -756,6 +933,9 @@ class _ReorderTile extends StatelessWidget { horizontal: AppSpacing.md, vertical: AppSpacing.md, ), + // The row looks identical whether the layer is hidden or not — no + // dimming, no cross-fade — so pressing the eye never makes anything + // appear to vanish. The eye itself is the only state indicator. child: Row( children: [ Icon(layer.icon, color: colors.onSurfaceVariant), @@ -768,6 +948,23 @@ class _ReorderTile extends StatelessWidget { ), ), ), + IconButton( + visualDensity: VisualDensity.compact, + tooltip: hidden ? l10n.mapLayerShow : l10n.mapLayerHide, + // Same color in both states — `outlineVariant` (meant for + // faint dividers) made the icon nearly invisible against the + // tile the moment `hidden` flipped true, so tapping it looked + // like the icon itself vanished. The row already says the + // glyph swap alone should carry the state. + color: colors.onSurfaceVariant, + // No press overlay: this button's only feedback is the icon + // itself swapping between the two glyphs, so a translucent + // state layer on top would just look like a second, competing + // signal for the same tap. + style: IconButton.styleFrom(overlayColor: Colors.transparent), + onPressed: canHide ? onToggleVisibility : null, + icon: Icon(hidden ? Icons.visibility_off : Icons.visibility), + ), ReorderableDragStartListener( index: index, child: const Padding( diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 81a06e20f..f3ee47659 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -5,6 +5,7 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/camera_fit.dart'; @@ -130,6 +131,13 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { /// Ranking → map: switch layer, frame station, open sheet. MapStationHandoff? _stationHandoff; + /// Hidden-layer set. Watched directly (not via the parent remounting on a + /// [ValueKey] change) so hiding the on-screen layer falls back through + /// [_onLayerSelected] in place — a remount would tear down this State and + /// close any sheet open above it, such as the layer-order editor the hide + /// itself was just tapped from. + MapLayerVisibilityController? _visibility; + late MapLayer _active = _resolveInitial(widget); static MapLayer _resolveInitial(MapScaffold widget) { @@ -257,6 +265,11 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { _stationHandoff?.removeListener(_onStationHandoff); _stationHandoff = station..addListener(_onStationHandoff); } + final visibility = context.read(); + if (visibility != _visibility) { + _visibility?.removeListener(_onVisibilityChanged); + _visibility = visibility..addListener(_onVisibilityChanged); + } final visibleTab = VisibleTabScope.of(context); if (identical(visibleTab, _visibleTab)) return; _visibleTab?.removeListener(_onTabChanged); @@ -349,6 +362,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { _basemapWarmer?.cancel(); _handoff?.removeListener(_onHandoff); _stationHandoff?.removeListener(_onStationHandoff); + _visibility?.removeListener(_onVisibilityChanged); super.dispose(); } @@ -1296,6 +1310,20 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { }); } + /// Hiding the on-screen layer from the picker's eye toggle must take it off + /// screen — nothing else would. Route the exit through [_onLayerSelected] so + /// the outgoing overlay is cleared exactly as a manual switch would. The + /// picker keeps listing hidden layers, so the user can always come back. + void _onVisibilityChanged() { + final visibility = _visibility; + if (visibility == null || !visibility.isHidden(_active.id)) return; + final candidate = widget.layers.firstWhere( + (layer) => !visibility.isHidden(layer.id), + orElse: () => widget.layers.first, + ); + if (candidate.id != _active.id) _onLayerSelected(candidate); + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/test/core/settings/map_layer_visibility_controller_test.dart b/test/core/settings/map_layer_visibility_controller_test.dart new file mode 100644 index 000000000..e96b0930b --- /dev/null +++ b/test/core/settings/map_layer_visibility_controller_test.dart @@ -0,0 +1,71 @@ +import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future controllerWith( + Map initial, +) async { + return MapLayerVisibilityController(SettingsStore.inMemory(initial)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('starts empty — every layer shown — when nothing was saved', () async { + final controller = await controllerWith({}); + expect(controller.hiddenIds, isEmpty); + expect(controller.isHidden('rts'), isFalse); + }); + + test('reads a previously saved hidden set', () async { + final controller = await controllerWith({ + 'map.layerHiddenIds': ['rts', 'lightning'], + }); + expect(controller.isHidden('rts'), isTrue); + expect(controller.isHidden('lightning'), isTrue); + expect(controller.isHidden('radar'), isFalse); + }); + + test('setHidden persists, notifies, and is idempotent', () async { + final controller = await controllerWith({}); + var notified = 0; + controller.addListener(() => notified++); + + await controller.setHidden('rts', hidden: true); + expect(controller.isHidden('rts'), isTrue); + expect(notified, 1); + + // Hiding an already-hidden layer changes nothing. + await controller.setHidden('rts', hidden: true); + expect(notified, 1, reason: 'an unchanged state must not notify'); + + // Showing it again persists the removal. + await controller.setHidden('rts', hidden: false); + expect(controller.isHidden('rts'), isFalse); + expect(notified, 2); + + // A fresh controller reads the persisted value. + final reloaded = await controllerWith({ + 'map.layerHiddenIds': ['radar'], + }); + expect(reloaded.isHidden('radar'), isTrue); + }); + + test('hiddenIds never leaks its internal set', () async { + final controller = await controllerWith({ + 'map.layerHiddenIds': ['rts'], + }); + expect(() => controller.hiddenIds.add('radar'), throwsUnsupportedError); + expect(controller.isHidden('radar'), isFalse); + }); + + test('unknown ids are tolerated', () async { + // An id saved by one surface but not offered by another must be inert + // there — surfaces resolve against their own layer sets. + final controller = await controllerWith({ + 'map.layerHiddenIds': ['not-a-layer'], + }); + expect(controller.isHidden('not-a-layer'), isTrue); + expect(controller.isHidden('rts'), isFalse); + }); +} diff --git a/test/shared/map/map_layer_switcher_test.dart b/test/shared/map/map_layer_switcher_test.dart index 62ef2825b..fd56d4778 100644 --- a/test/shared/map/map_layer_switcher_test.dart +++ b/test/shared/map/map_layer_switcher_test.dart @@ -1,4 +1,5 @@ import 'package:dpip/core/settings/map_layer_order_controller.dart'; +import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_layer.dart'; @@ -52,10 +53,19 @@ void main() { tester.view.physicalSize = const Size(800, 1600); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); - final controller = MapLayerOrderController(SettingsStore.inMemory(initial)); + final settings = SettingsStore.inMemory(initial); + final controller = MapLayerOrderController(settings); + final visibility = MapLayerVisibilityController(settings); await tester.pumpWidget( - ChangeNotifierProvider.value( - value: controller, + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: controller, + ), + ChangeNotifierProvider.value( + value: visibility, + ), + ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -188,7 +198,8 @@ void main() { closeTo(screenWidth(tester) / 2, 1), ); // Level 1 is the category list: one drag handle per category, no layer - // rows, no chevron on a single-layer category (radar, typhoon). + // rows. Every category shows a chevron — single-layer ones too, since + // the visibility eyes live on level 2. expect(find.byIcon(Icons.drag_handle), findsNWidgets(5)); Finder inEditor(String text) => find.descendant( of: find.byType(ReorderableListView), @@ -200,13 +211,77 @@ void main() { expect(inEditor(l10n.mapLayerCategoryWeather), findsOneWidget); expect(inEditor(l10n.mapLayerCategorySatellite), findsOneWidget); // The picker behind still shows the active layer's tile — the editor's - // level-1 list must not. Only forecast holds more than one layer, so just - // one category is drill-in-able. + // level-1 list must not. expect(inEditor('Radar echo'), findsNothing); - expect(find.byIcon(Icons.chevron_right), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsNWidgets(5)); expect(find.byIcon(Icons.close), findsOneWidget); }); + testWidgets('a single-layer category opens and hides its only layer', ( + tester, + ) async { + await pumpSwitcher(tester, {}); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of( + tester.element(find.byType(MapLayerSwitcher)), + ); + await tester.tap( + find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(l10n.mapLayerCategoryRadar), + ), + ); + await tester.pumpAndSettle(); + + // The radar row is reachable on level 2 with a working eye — hiding it + // must not be blocked by the category holding just one layer. Scoped to + // the reorder list: the "show all" / "hide all" row above it has its own + // visibility icons. + final rows = find.byType(ReorderableListView); + expect( + find.descendant(of: rows, matching: find.byIcon(Icons.visibility)), + findsOneWidget, + ); + await tester.tap( + find.descendant(of: rows, matching: find.byIcon(Icons.visibility)), + ); + await tester.pumpAndSettle(); + expect( + find.descendant(of: rows, matching: find.byIcon(Icons.visibility_off)), + findsOneWidget, + ); + + // Back to level 1, close the editor, then look at the picker. + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + // Back on the picker: hiding drops the tile entirely, not just dims it — + // and radar's now-empty category (nothing else belongs to it) drops out + // of the list too. The editor's eye remains the only way back. + final sheet = find.byType(DraggableScrollableSheet); + expect( + find.descendant(of: sheet, matching: find.text('Radar echo')), + findsNothing, + ); + expect( + find.descendant( + of: sheet, + matching: find.text(l10n.mapLayerCategoryRadar), + ), + findsNothing, + ); + expect( + find.descendant(of: sheet, matching: find.text('Rain')), + findsOneWidget, + ); + }); + testWidgets('tapping a category opens its layer list and back returns', ( tester, ) async { @@ -245,6 +320,152 @@ void main() { expect(editorText(l10n.mapLayerCategoryRadar), findsOneWidget); }); + testWidgets('a hidden layer is left out of the picker', (tester) async { + await pumpSwitcher(tester, { + 'map.layerHiddenIds': ['qpesums'], + }); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + + // The hidden layer drops out of the list entirely — nothing dims it, + // nothing marks it. Its still-visible category siblings are unaffected. + expect(find.text('Precip'), findsNothing); + expect(find.byIcon(Icons.visibility_off), findsNothing); + expect(find.text('ECMWF'), findsOneWidget); + expect(find.text('Rain'), findsOneWidget); + }); + + testWidgets('the editor eye toggle hides a layer live', (tester) async { + await pumpSwitcher(tester, {}); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + await tester.tap( + find.descendant( + of: find.byType(ReorderableListView), + matching: find.text( + AppLocalizations.of(tester.element(find.byType(MapLayerSwitcher))) + .mapLayerCategoryForecast, + ), + ), + ); + await tester.pumpAndSettle(); + + // Three visible layers, three eyes. Scoped to the reorder list: the + // "show all" / "hide all" row above it has its own visibility icons. + // Hide the first row's layer. + final rows = find.byType(ReorderableListView); + Finder rowIcon(IconData icon) => + find.descendant(of: rows, matching: find.byIcon(icon)); + expect(rowIcon(Icons.visibility), findsNWidgets(3)); + await tester.tap(rowIcon(Icons.visibility).first); + await tester.pumpAndSettle(); + expect(rowIcon(Icons.visibility), findsNWidgets(2)); + expect(rowIcon(Icons.visibility_off), findsOneWidget); + + // Back to level 1, close the editor: back on the picker the hidden + // layer is gone — the order editor's eye is the only way to offer it + // again. + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byType(DraggableScrollableSheet), + matching: find.text('Precip'), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byType(DraggableScrollableSheet), + matching: find.byIcon(Icons.visibility_off), + ), + findsNothing, + ); + }); + + testWidgets('show all / hide all toggle every layer in the open category', ( + tester, + ) async { + await pumpSwitcher(tester, {}); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of( + tester.element(find.byType(MapLayerSwitcher)), + ); + await tester.tap( + find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(l10n.mapLayerCategoryForecast), + ), + ); + await tester.pumpAndSettle(); + + final rows = find.byType(ReorderableListView); + Finder rowIcon(IconData icon) => + find.descendant(of: rows, matching: find.byIcon(icon)); + + expect(rowIcon(Icons.visibility), findsNWidgets(3)); + await tester.tap(find.widgetWithText(OutlinedButton, l10n.mapLayerHideAll)); + await tester.pumpAndSettle(); + expect(rowIcon(Icons.visibility_off), findsNWidgets(3)); + + await tester.tap(find.widgetWithText(OutlinedButton, l10n.mapLayerShowAll)); + await tester.pumpAndSettle(); + expect(rowIcon(Icons.visibility), findsNWidgets(3)); + }); + + testWidgets( + "hide all keeps the surface's last visible layer even inside the open " + 'category', + (tester) async { + // Every other category already hidden — the open one (forecast) holds + // the only layers left on screen. + await pumpSwitcher(tester, { + 'map.layerHiddenIds': ['radar', 'typhoon', 'rain', 'satellite'], + }); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of( + tester.element(find.byType(MapLayerSwitcher)), + ); + await tester.tap( + find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(l10n.mapLayerCategoryForecast), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.widgetWithText(OutlinedButton, l10n.mapLayerHideAll), + ); + await tester.pumpAndSettle(); + + // "Hide all" can't take the surface's last layer — one row stays + // visible instead of all three going dark. + final rows = find.byType(ReorderableListView); + expect( + find.descendant(of: rows, matching: find.byIcon(Icons.visibility)), + findsOneWidget, + ); + expect( + find.descendant(of: rows, matching: find.byIcon(Icons.visibility_off)), + findsNWidgets(2), + ); + }, + ); + testWidgets('dragging a category reorders the categories', (tester) async { final controller = await pumpSwitcher(tester, {}); await tester.tap(find.text('Radar echo')); From eeeeb7a3ec4580155aaa3624f3ed8eb9a48c35fd Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 24 Aug 2026 15:32:34 +0800 Subject: [PATCH 31/40] perf(map): draw wind forecast particles natively on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Optimization(zh-Hant): 風場粒子動畫在 iOS 改用原生渲染,更流暢省電 Optimization(en-US): wind particles render through the native path on iOS too --- .../layers/wind_forecast_layer.dart | 23 +++++++++++-------- .../layers/wind_particle_native.dart | 20 +++++++++++----- .../map/wind_particle_native_test.dart | 4 ++-- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart index 3484eb2cd..8fba9e27f 100644 --- a/lib/features/map/presentation/layers/wind_forecast_layer.dart +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -27,8 +27,9 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// Everything about scrubbing lives in [RasterTimelineLayer]; this supplies the /// model's identity, its opacity, the shared wind-speed colour key, the two /// admin-border overlays its options chip toggles ([AdminOutlineChrome]), and -/// the particle animation ([WindParticleOverlay]) that rides the loaded -/// [field]. +/// the particle animation — native on Android and iOS +/// ([WindParticleNative]), [WindParticleOverlay] elsewhere — driven by the +/// loaded [field]. /// /// The tiles are a semi-transparent speed wash, so the layer draws its own /// county / township borders **over** the field the same way radar does over @@ -50,10 +51,12 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { /// The GPU renderer that draws the particles inside the map. /// - /// Where it is available it replaces [WindParticleOverlay] entirely. That is - /// not a preference: a Flutter overlay repainting above an Android platform - /// view leaks a full-screen graphics buffer per frame under HCPP, and the - /// particles were the only thing in the app repainting every frame. + /// Where it is available it replaces [WindParticleOverlay] entirely. On + /// Android that is not a preference but the leak escape: a Flutter overlay + /// repainting above a platform view leaks a full-screen graphics buffer per + /// frame under HCPP, and the particles were the only thing in the app + /// repainting every frame. iOS has no leak, but the same native path is where + /// map content belongs and keeps one wire for both. late final WindParticleNative particles = WindParticleNative( field: field, interacting: interacting, @@ -185,10 +188,10 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { /// The Flutter overlay, only where the map cannot draw the particles itself. /// - /// On Android the native layer carries them and this is deliberately empty — - /// returning the overlay anyway would reintroduce the per-frame Flutter - /// presentation that the whole port exists to remove. It is still the real - /// implementation everywhere else. + /// Where the native layer carries them (Android, iOS) this is deliberately + /// empty — returning the overlay anyway would put a per-frame Flutter + /// presentation back on platforms that no longer need one. It is still the + /// real implementation everywhere else. @override Widget buildMapOverlay(BuildContext context) => particles.isActive ? const SizedBox.shrink() diff --git a/lib/features/map/presentation/layers/wind_particle_native.dart b/lib/features/map/presentation/layers/wind_particle_native.dart index 3352cc00f..e6f464212 100644 --- a/lib/features/map/presentation/layers/wind_particle_native.dart +++ b/lib/features/map/presentation/layers/wind_particle_native.dart @@ -20,10 +20,14 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// 8 GB of GPU memory in sixteen seconds. Drawn inside the map instead, the /// particles produce no Flutter frame at all. /// +/// iOS draws inside the map too, through `MLNCustomStyleLayer`'s Metal encoder +/// (see `WindParticleEngine.swift` in the fork) — there was never a leak there, +/// but the same pass is where map content belongs, and one wire feeds both. +/// /// This class owns only the *conversation* with that renderer — when to add it, /// what to upload, when to let it run. The simulation itself is gone from Dart; -/// [WindParticleSim] survives as the numeric oracle its GLSL twin is checked -/// against, not as something that runs in production. +/// [WindParticleSim] survives as the numeric oracle the native shaders are +/// checked against, not as something that runs in production. class WindParticleNative { WindParticleNative({ required this.field, @@ -64,10 +68,14 @@ class WindParticleNative { /// Whether this platform should even try. /// - /// Android only, for now. The leak this exists to avoid is in the Android - /// SurfaceControl path, and iOS would need the whole shader set rewritten in - /// Metal for a problem it does not have. - bool get isSupported => _platform == TargetPlatform.android; + /// Android and iOS. The Android path is the reason this class exists: the + /// HCPP overlay leak lives in that platform's SurfaceControl, and the map's + /// GL surface is where drawing costs nothing (see the native layer's class + /// comment). iOS draws through `MLNCustomStyleLayer`'s Metal encoder in the + /// map's own pass — same reasoning, no leak to escape — and arrived later; + /// see `WindParticleEngine.swift` in the fork for how its passes are split. + bool get isSupported => + _platform == TargetPlatform.android || _platform == TargetPlatform.iOS; void attach(MapLibreMapController controller) { if (!isSupported || _unavailable) return; diff --git a/test/features/map/wind_particle_native_test.dart b/test/features/map/wind_particle_native_test.dart index fbbf87405..851314610 100644 --- a/test/features/map/wind_particle_native_test.dart +++ b/test/features/map/wind_particle_native_test.dart @@ -157,13 +157,13 @@ void main() { platform: platform, ); - test('Android is the platform this exists for', () { + test('Android and iOS are the platforms this exists for', () { expect(build(TargetPlatform.android).isSupported, isTrue); + expect(build(TargetPlatform.iOS).isSupported, isTrue); }); test('nowhere else, and nothing is active before it attaches', () { for (final platform in const [ - TargetPlatform.iOS, TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux, From 9484f9d9a1cb8f1ddfc5d7d0cccbf8afed8336f7 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 12:37:19 +0800 Subject: [PATCH 32/40] build(deps): bump the maplibre fork for wind-layer position fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins 9804c2f: screen-space trail buffers are now invalidated whenever the camera moves, when a new forecast field lands, and after a lost EGL context — the native particle layer had lost the overlay's drop-on-move behaviour, so pans, pinches and rotations smeared streaks anchored to the old camera over the new one. --- pubspec.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index 41301ca61..39756701c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,13 +58,13 @@ dependencies: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl - ref: 61a5fd658976ec0f4be838da71bc271a3e9fc7b8 + ref: 9804c2f9a10c03373e596acb7045b4c2e91a1fe2 # Direct (not just override) so tests can import the platform interface. maplibre_gl_platform_interface: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl_platform_interface - ref: 61a5fd658976ec0f4be838da71bc271a3e9fc7b8 + ref: 9804c2f9a10c03373e596acb7045b4c2e91a1fe2 # LoRa mesh (Meshtastic) over BLE — off-grid emergency messaging. Requires # Bluetooth + location permissions (Android manifest / iOS Info.plist below). # Vendored (third_party/) with two upstream fixes: requestMtu is skipped off @@ -123,12 +123,12 @@ dependency_overrides: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl_platform_interface - ref: 61a5fd658976ec0f4be838da71bc271a3e9fc7b8 + ref: 9804c2f9a10c03373e596acb7045b4c2e91a1fe2 maplibre_gl_web: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl_web - ref: 61a5fd658976ec0f4be838da71bc271a3e9fc7b8 + ref: 9804c2f9a10c03373e596acb7045b4c2e91a1fe2 flutter: config: From f21d05ee6d103156e0a2da5a7bcaf3a22af5a0be Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 13:11:06 +0800 Subject: [PATCH 33/40] fix(notify): present startup-window pushes iOS declined to show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正 iOS 冷啟動窗口內收到的推播被靜默丟棄——App 提早接手通知代理,awesome 的呈現器無法顯示時由系統補上橫幅與音效 Fix(en-US): fix pushes arriving in the cold-start window being silently dropped on iOS — the app claims the notification-center delegate early and presents whatever awesome's presenter declines --- ios/Runner/AppDelegate.swift | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b5fd33cbc..058d00a29 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import Flutter import UIKit +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -7,9 +8,31 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // Claim the notification-center delegate BEFORE awesome_notifications' + // own didFinishLaunching observer does. awesome captures whoever is set + // at that point as its "original delegate" and only forwards to it when + // its own status-bar presenter declines — which is exactly the cold-start + // window where that presenter is not ready yet and pushes were swallowed + // whole (completionHandler([]) with nobody left to ask). Presenting here + // plays the aps sound exactly once; when the presenter succeeds instead, + // this is never called. + UNUserNotificationCenter.current().delegate = self + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + /// Presents pushes awesome's own status-bar presenter declined — the cold- + /// start window before that presenter is ready. Banner plus the payload's + /// own sound, played once by the system. + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) + -> Void + ) { + completionHandler([.list, .banner, .sound]) + } + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { let registry = engineBridge.pluginRegistry // Firebase and other pub plugins. From daf87fa63c9d5e25345e736649e7deedaa87ca9b Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 22:02:32 +0800 Subject: [PATCH 34/40] fix(notify): bundle the Firebase config iOS builds were missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正 iOS 啟動時 Firebase 設定檔缺失 Fix(en-US): fix the missing Firebase configuration on iOS startup --- ios/Runner.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index bdcdea90b..d2b212c7e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 17BD5D769AA990E7EE681203 /* eew_alert.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 4D00D2962CF4598B61D0C722 /* eew_alert.aiff */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */; }; 522508B9301F863A006148C2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 522508B7301F863A006148C2 /* InfoPlist.strings */; }; 72E4CBC23930C168D057AC64 /* warn.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 3AE87ED82FDB896B2B5C5F1B /* warn.aiff */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -88,6 +89,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; A382CD9DEA741E45DBF741D7 /* rain.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/rain.aiff; sourceTree = ""; }; AA0000000000000000000C01 /* CompassPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassPlugin.swift; sourceTree = ""; }; AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = ""; }; @@ -167,6 +169,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */, 522508B7301F863A006148C2 /* InfoPlist.strings */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, @@ -298,6 +301,7 @@ files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, 522508B9301F863A006148C2 /* InfoPlist.strings in Resources */, From b2eb063dfeaf5d66ae86021e8f381925f91a4489 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 22:02:42 +0800 Subject: [PATCH 35/40] refactor(notify): hand remote push to awesome_notifications_fcm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 推播改由單一管線處理,兩個平台行為一致 Optimization(en-US): push now runs through one pipeline, the same on both platforms --- .../xcshareddata/swiftpm/Package.resolved | 9 ++ .../xcshareddata/swiftpm/Package.resolved | 9 ++ .../notifications/notification_service.dart | 144 ++++++++++++++---- lib/core/notifications/notification_taps.dart | 6 +- pubspec.yaml | 8 + 5 files changed, 147 insertions(+), 29 deletions(-) diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 11c5949ca..2f2801e7f 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,6 +99,15 @@ "version" : "0.12.1" } }, + { + "identity" : "iosawnfcmcore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rafaelsetragni/IosAwnFcmCore.git", + "state" : { + "revision" : "4c914884b3c0213284df7bcd8237c469ed58f4d2", + "version" : "0.12.0" + } + }, { "identity" : "leveldb", "kind" : "remoteSourceControl", diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 11c5949ca..2f2801e7f 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,6 +99,15 @@ "version" : "0.12.1" } }, + { + "identity" : "iosawnfcmcore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rafaelsetragni/IosAwnFcmCore.git", + "state" : { + "revision" : "4c914884b3c0213284df7bcd8237c469ed58f4d2", + "version" : "0.12.0" + } + }, { "identity" : "leveldb", "kind" : "remoteSourceControl", diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 6b8407680..b6e616496 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -3,11 +3,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:awesome_notifications_fcm/awesome_notifications_fcm.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/permissions/permission_outcome.dart'; import 'package:dpip/core/permissions/system_settings.dart'; import 'package:dpip/core/notifications/notification_channels.dart'; -import 'package:dpip/core/notifications/notification_tap.dart'; import 'package:dpip/core/notifications/notification_taps.dart'; import 'package:dpip/core/notifications/plain_channels.dart'; import 'package:dpip/core/settings/setting_keys.dart'; @@ -77,6 +77,14 @@ class NotificationService { await _initChannels(); await AwesomeNotifications().setListeners( onActionReceivedMethod: NotificationTaps.onActionReceived, + // Kept for operational visibility, not for one bug. `created` fires when + // awesome accepts a notification and `displayed` when it reaches the + // status bar, so the log answers "did the alert actually surface?" — the + // question that matters most in an app whose reason to exist is alerts, + // and the one that took five rebuilds to answer the last time it came up + // because nothing recorded it. + onNotificationCreatedMethod: onNotificationCreated, + onNotificationDisplayedMethod: onNotificationDisplayed, ); await _initMessaging(); } @@ -345,17 +353,39 @@ class NotificationService { Future _initMessaging() async { final messaging = FirebaseMessaging.instance; - FirebaseMessaging.onBackgroundMessage(onBackgroundMessage); - FirebaseMessaging.onMessage.listen((message) { - final content = contentFromMessage(message); - if (content != null) { - AwesomeNotifications().createNotification(content: content); - } - }); - FirebaseMessaging.onMessageOpenedApp.listen((m) => _routeTap(m.data)); + // Tell iOS what to do with a foreground push, because the default is + // nothing. + // + // firebase_messaging's iOS delegate reads these from NSUserDefaults and, + // when the key was never written, answers the system with + // `UNNotificationPresentationOptionNone` — silence, no banner. Nothing else + // writes that key, so an app that never calls this has a foreground that is + // off by default. It costs nothing when another delegate is in front. + await messaging.setForegroundNotificationPresentationOptions( + alert: true, + badge: true, + sound: true, + ); - final initial = await messaging.getInitialMessage(); - if (initial != null) _routeTap(initial.data); + // Push is awesome_notifications_fcm's job, on both platforms. + // + // `awesome_notifications` handles local notifications only — its own source + // says so: "we do not chain to a previously-installed delegate … FCM is + // handled by awesome_notifications_fcm". Without that companion the remote + // path has no owner: on iOS a server push reached awesome's willPresent, + // was claimed as its own (the payload's `content` key is awesome's model + // format) and then had nothing to display it with, so the foreground went + // silent and blank. + // + // This is the pre-rewrite arrangement, restored. `firebase_messaging` stays + // for the APNs token below — upstream says the two must not coexist, but + // the app shipped them together for years and the token path depends on it. + await AwesomeNotificationsFcm().initialize( + onFcmTokenHandle: _onPushToken, + onNativeTokenHandle: _onPushToken, + onFcmSilentDataHandle: onFcmSilentData, + debug: kDebugMode, + ); messaging.onTokenRefresh.listen((token) async { Log.debug('Push token refreshed'); @@ -399,6 +429,20 @@ class NotificationService { /// the APNs auth key uploaded to the Firebase console. Best-effort: a failure /// just leaves the token unset until [requestPermission] or /// `onTokenRefresh` tries again. + /// Stores a push token handed over by awesome_notifications_fcm. + /// + /// Both handlers land here on purpose: `onFcmTokenHandle` fires with the FCM + /// registration token and `onNativeTokenHandle` with the raw APNs one, and + /// each platform is given only the one it can produce. Which of the two the + /// backend needs is not symmetric — see [_fetchToken] — so the platform test + /// stays rather than trusting whichever arrived last. + @pragma('vm:entry-point') + Future _onPushToken(String token) async { + if (token.isEmpty) return; + await _settings.setString(SettingKeys.pushToken, token); + Log.debug('Push token received (${token.length} chars)'); + } + Future _fetchToken() async { final messaging = FirebaseMessaging.instance; try { @@ -427,9 +471,6 @@ class NotificationService { Log.handle(error, stackTrace, title); } } - - void _routeTap(Map data) => - NotificationTaps.route(NotificationTap.fromData(data)); } /// Builds notification content from a message's `data` (preferred, legacy @@ -449,9 +490,23 @@ class NotificationService { /// /// Flat keys win where both exist. Nested JSON is parsed leniently: malformed /// or non-object content is treated as absent, never thrown on. -NotificationContent? contentFromMessage(RemoteMessage message) { - final data = message.data; - final notification = message.notification; +NotificationContent? contentFromMessage(RemoteMessage message) => + contentFromData( + message.data, + fallbackTitle: message.notification?.title, + fallbackBody: message.notification?.body, + ); + +/// The same, from a bare data map — what awesome_notifications_fcm delivers. +/// +/// [fallbackTitle] / [fallbackBody] stand in for the FCM `notification` block, +/// which only the [RemoteMessage] shape carries. A silent-data push has no such +/// block, so its text has to come from the payload itself. +NotificationContent? contentFromData( + Map data, { + String? fallbackTitle, + String? fallbackBody, +}) { final nested = _nestedContent(data); String? nestedField(String name) => switch (nested?[name]) { final String value => value, @@ -459,9 +514,8 @@ NotificationContent? contentFromMessage(RemoteMessage message) { _ => null, }; final title = - (data['title'] as String?) ?? notification?.title ?? nestedField('title'); - final body = - (data['body'] as String?) ?? notification?.body ?? nestedField('body'); + (data['title'] as String?) ?? fallbackTitle ?? nestedField('title'); + final body = (data['body'] as String?) ?? fallbackBody ?? nestedField('body'); if (title == null && body == null) return null; final channelKey = (data['channel'] as String?) ?? @@ -519,18 +573,54 @@ int? _asNotificationId(Object? value) { return parsed; } -/// Displays a background/terminated **data-only** message via awesome (a -/// `notification`-payload message is shown by the OS itself). Runs on a -/// background isolate, so awesome must be initialized here before use. +/// Fires when awesome accepts a notification, before it is shown. @pragma('vm:entry-point') -Future onBackgroundMessage(RemoteMessage message) async { - if (message.notification != null) return; - final content = contentFromMessage(message); - if (content == null) return; +Future onNotificationCreated(ReceivedNotification notification) async { + Log.debug( + 'notif created: id=${notification.id} channel=${notification.channelKey} ' + 'lifecycle=${notification.createdLifeCycle}', + ); +} + +/// Fires when a notification actually reaches the status bar. +@pragma('vm:entry-point') +Future onNotificationDisplayed(ReceivedNotification notification) async { + Log.debug( + 'notif displayed: id=${notification.id} channel=${notification.channelKey} ' + 'lifecycle=${notification.displayedLifeCycle}', + ); +} + +/// Draws a push that arrived through awesome_notifications_fcm. +/// +/// Runs on a background isolate when the app is not in the foreground, so +/// awesome has to be initialized here before it can be used — the isolate does +/// not inherit the one `init()` set up. +/// +/// The terminated case goes through `createNotificationFromJsonData` rather +/// than a hand-built [NotificationContent]: at that point there is no engine +/// state to rely on, and the payload is already in awesome's own wire format +/// (the server sends a `content` object with `channelKey`), so handing it over +/// whole is both shorter and closer to what the sender meant. +@pragma('vm:entry-point') +Future onFcmSilentData(FcmSilentData silentData) async { + final data = silentData.data; + if (data == null || data.isEmpty) return; + await AwesomeNotifications().initialize( NotificationChannels.icon, NotificationChannels.channels, channelGroups: NotificationChannels.groups, ); + + if (silentData.createdLifeCycle == NotificationLifeCycle.Terminated) { + await AwesomeNotifications().createNotificationFromJsonData( + data.cast(), + ); + return; + } + + final content = contentFromData(data.cast()); + if (content == null) return; await AwesomeNotifications().createNotification(content: content); } diff --git a/lib/core/notifications/notification_taps.dart b/lib/core/notifications/notification_taps.dart index a4c9b699e..40ebf8103 100644 --- a/lib/core/notifications/notification_taps.dart +++ b/lib/core/notifications/notification_taps.dart @@ -20,8 +20,10 @@ abstract final class NotificationTaps { /// Routes [tap] now via [onTap], or stashes it for [drainPending] if the app / /// router isn't ready yet (cold start). Shared by awesome-displayed taps - /// ([onActionReceived]) and FCM-delivered taps (firebase's - /// `onMessageOpenedApp` / `getInitialMessage`). + /// ([onActionReceived]). Firebase's `onMessageOpenedApp` / + /// `getInitialMessage` no longer feed this: push is owned by + /// awesome_notifications_fcm, so every notification the user can tap was + /// displayed by awesome and arrives through [onActionReceived]. static void route(NotificationTap tap) { final handler = onTap; if (handler != null) { diff --git a/pubspec.yaml b/pubspec.yaml index 39756701c..25894d5de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,14 @@ environment: dependencies: awesome_notifications: ^0.12.1 + # The remote-push half of awesome. `awesome_notifications` alone handles only + # local notifications: its own source says "we do not chain to a + # previously-installed delegate … FCM is handled by awesome_notifications_fcm". + # Without it a server push reaches awesome's willPresent, is claimed as its own + # (the payload's `content` key is awesome's own model format), and then has no + # pipeline to display it — the app showed nothing in the foreground. The + # pre-rewrite app shipped this package; the rewrite dropped it. + awesome_notifications_fcm: ^0.12.0 cupertino_icons: ^1.0.8 dio: ^5.10.0 # Firebase pinned deliberately — a newer major bumps the min iOS target and From 96dc057d870d5c9d65d7260c3cba403560f0888e Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 22:02:51 +0800 Subject: [PATCH 36/40] fix(notify): show foreground pushes iOS was leaving silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正 App 開啟時收到的推播不會顯示也沒有聲音 Fix(en-US): fix pushes that arrived with the app open showing nothing and playing no sound --- ios/Runner/AppDelegate.swift | 146 ++++++++++++++++++++++++++++++----- 1 file changed, 127 insertions(+), 19 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 058d00a29..4a1261707 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -8,29 +8,15 @@ import UserNotifications _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Claim the notification-center delegate BEFORE awesome_notifications' - // own didFinishLaunching observer does. awesome captures whoever is set - // at that point as its "original delegate" and only forwards to it when - // its own status-bar presenter declines — which is exactly the cold-start - // window where that presenter is not ready yet and pushes were swallowed - // whole (completionHandler([]) with nobody left to ask). Presenting here - // plays the aps sound exactly once; when the presenter succeeds instead, - // this is never called. - UNUserNotificationCenter.current().delegate = self return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - /// Presents pushes awesome's own status-bar presenter declined — the cold- - /// start window before that presenter is ready. Banner plus the payload's - /// own sound, played once by the system. - override func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) - -> Void - ) { - completionHandler([.list, .banner, .sound]) + override func applicationDidBecomeActive(_ application: UIApplication) { + super.applicationDidBecomeActive(application) + // Re-assert: firebase_messaging's proxy can claim the delegate again from + // its own launch observer, and whoever is last wins. + NotificationDelegateProxy.shared.install() } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { @@ -51,5 +37,127 @@ import UserNotifications with: registry.registrar(forPlugin: "BackgroundLocationPlugin")!) BackgroundExecutionPlugin.register( with: registry.registrar(forPlugin: "BackgroundExecutionPlugin")!) + + // Re-post the launch notification the plugins just missed. + // + // This callback is *deferred* registration — Flutter's own header calls it + // that. Under the UISceneDelegate lifecycle the implicit engine is built + // lazily, so plugins are registered here, well after UIKit has already + // posted `UIApplication.didFinishLaunchingNotification`. A plugin that + // waits for that notification instead of implementing + // `application:didFinishLaunchingWithOptions:` therefore never hears it. + // + // awesome_notifications is one: it observes the notification + // (AwesomeNotifications.swift:156) and only inside the handler does it set + // `UNUserNotificationCenter.current().delegate = self` (:508). Miss it and + // the app runs with **no notification-centre delegate at all** — which iOS + // reads as "never present a notification while the app is in the + // foreground". Background delivery is unaffected because it needs no + // delegate, which is exactly the shape of the bug: pushes arrived normally + // with the app closed and vanished with it open. + // + // Posting it again is narrow by construction: the only observers that can + // be here are ones registered moments ago in this very method, and they + // have not seen it once. + NotificationDelegateProxy.shared.install() + } +} + +/// Answers iOS for pushes that no plugin will answer for. +/// +/// The app's pushes are published straight to APNs by AWS SNS — no FCM, no +/// `mutable-content`, no Notification Service Extension. awesome_notifications +/// cannot render such a push: its own README requires all three. That is why +/// they arrive correctly in the background — awesome is bypassed entirely and +/// iOS presents `aps` itself — and vanish in the foreground, where Apple hands +/// the decision to whatever holds the notification-centre delegate. +/// +/// awesome holds it, and for these pushes it answers nothing at all: its +/// `willPresent` calls `showNotificationOnStatusBar`, which throws when the +/// channel is not in its native registry, and the surrounding `catch` +/// (AwesomeNotifications.swift:666) never calls the completion handler. +/// `StatusBarManager.swift:118` has the same shape — a bare `return` past the +/// handler. Apple's contract has no timeout for that: "if the handler is not +/// called in a timely manner then the notification will not be presented". +/// No banner, no sound, no log. +/// +/// This proxy sits in front and restores the documented default for exactly +/// those pushes — the same presentation the background already gets — while +/// forwarding everything else, taps included, to awesome untouched. +final class NotificationDelegateProxy: NSObject, UNUserNotificationCenterDelegate { + static let shared = NotificationDelegateProxy() + + /// The delegate awesome installed, kept so its own notifications still work. + /// + /// Strongly held on purpose: `UNUserNotificationCenter.delegate` is `weak`, + /// so a proxy nobody retains would be released the moment `install()` + /// returns — leaving the delegate nil and the bug apparently "fixed" for the + /// wrong reason. + private var wrapped: UNUserNotificationCenterDelegate? + + /// Takes the delegate, remembering whoever had it. + /// + /// Idempotent, and safe to call repeatedly: installing over ourselves would + /// otherwise make `wrapped` point at this proxy and every forward recurse. + func install() { + let center = UNUserNotificationCenter.current() + if center.delegate === self { return } + wrapped = center.delegate + center.delegate = self + } + + /// Whether this notification is one the server sent straight to APNs. + /// + /// FCM stamps every message it delivers with `gcm.message_id`; ours has none + /// and carries the `content` object the backend sends instead. Anything else + /// — including notifications awesome created locally — is not ours to answer. + private func isServerPush(_ notification: UNNotification) -> Bool { + let info = notification.request.content.userInfo + return info["gcm.message_id"] == nil && info["content"] != nil + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + if isServerPush(notification) { + #if DEBUG + // stderr, not NSLog: `flutter run` on a device relays only the former. + fputs("DPIP-NOTIF [proxy] presenting server push\n", stderr) + fflush(stderr) + #endif + completionHandler([.banner, .list, .badge, .sound]) + return + } + guard + let wrapped, + wrapped.responds( + to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:))) + else { + completionHandler([.banner, .list, .badge, .sound]) + return + } + wrapped.userNotificationCenter?( + center, willPresent: notification, withCompletionHandler: completionHandler) + } + + /// Taps are never ours. awesome owns action routing and `getInitialAction`, + /// and intercepting here would break deep links from a notification. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + guard + let wrapped, + wrapped.responds( + to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:))) + else { + completionHandler() + return + } + wrapped.userNotificationCenter?( + center, didReceive: response, withCompletionHandler: completionHandler) } } From 75765c5c4e3f7b4b65f267b71d77f7ffc526b93f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 22:09:31 +0800 Subject: [PATCH 37/40] refactor(notify): give the push token one writer instead of four MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正 iOS 推播 token 可能被錯誤型別覆蓋,導致推播收不到 Fix(en-US): fix the iOS push token being overwritten with the wrong kind, which stopped pushes arriving --- .../notifications/notification_service.dart | 67 +++++++--------- ..._test.dart => content_from_data_test.dart} | 77 +++++++++---------- 2 files changed, 64 insertions(+), 80 deletions(-) rename test/core/notifications/{content_from_message_test.dart => content_from_data_test.dart} (66%) diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index b6e616496..1fbf5c138 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -381,25 +381,20 @@ class NotificationService { // for the APNs token below — upstream says the two must not coexist, but // the app shipped them together for years and the token path depends on it. await AwesomeNotificationsFcm().initialize( - onFcmTokenHandle: _onPushToken, - onNativeTokenHandle: _onPushToken, + onFcmTokenHandle: (token) => _storeToken(token, isApns: false), + onNativeTokenHandle: (token) => _storeToken(token, isApns: true), onFcmSilentDataHandle: onFcmSilentData, debug: kDebugMode, ); + // This stream only ever carries the FCM registration token, so on iOS the + // rotation is the signal and the APNs token is what has to be re-read. messaging.onTokenRefresh.listen((token) async { - Log.debug('Push token refreshed'); - if (defaultTargetPlatform == TargetPlatform.iOS) { - // This stream only carries the FCM registration token (see - // [_fetchToken] for why that's not what iOS registration needs); - // re-read the APNs token directly rather than persist [token] as-is. + await _storeToken(token, isApns: false); + if (Platform.isIOS) { final apns = await messaging.getAPNSToken(); - if (apns != null) { - await _settings.setString(SettingKeys.pushToken, apns); - } - return; + if (apns != null) await _storeToken(apns, isApns: true); } - await _settings.setString(SettingKeys.pushToken, token); }); // Fire-and-forget: this can wait seconds for the iOS APNs token, and // `init()` is awaited at launch, so it must not block start-up. @@ -429,18 +424,24 @@ class NotificationService { /// the APNs auth key uploaded to the Firebase console. Best-effort: a failure /// just leaves the token unset until [requestPermission] or /// `onTokenRefresh` tries again. - /// Stores a push token handed over by awesome_notifications_fcm. + /// The one place [SettingKeys.pushToken] is written. + /// + /// Tokens arrive from three directions — awesome_notifications_fcm's two + /// handlers, firebase's refresh stream, and the launch-time fetch — and the + /// two kinds are **not interchangeable**: the backend keys on the raw APNs + /// token on iOS and the FCM registration token on Android. Registering the + /// wrong one is not a loud failure; it was measured to 202 on the write and + /// then 401 on every later lookup, which reads as "push is broken" with no + /// clue why. /// - /// Both handlers land here on purpose: `onFcmTokenHandle` fires with the FCM - /// registration token and `onNativeTokenHandle` with the raw APNs one, and - /// each platform is given only the one it can produce. Which of the two the - /// backend needs is not symmetric — see [_fetchToken] — so the platform test - /// stays rather than trusting whichever arrived last. - @pragma('vm:entry-point') - Future _onPushToken(String token) async { + /// So every writer states which kind it holds and this decides, instead of + /// each caller repeating a platform test — the version that did not repeat + /// it let iOS overwrite a good APNs token with an FCM one seconds later. + Future _storeToken(String token, {required bool isApns}) async { if (token.isEmpty) return; + if (isApns != Platform.isIOS) return; await _settings.setString(SettingKeys.pushToken, token); - Log.debug('Push token received (${token.length} chars)'); + Log.debug('Push token stored (${isApns ? 'APNs' : 'FCM'})'); } Future _fetchToken() async { @@ -455,12 +456,8 @@ class NotificationService { } } final fcmToken = await messaging.getToken(); - final pushToken = defaultTargetPlatform == TargetPlatform.iOS - ? apnsToken - : fcmToken; - if (pushToken != null) { - await _settings.setString(SettingKeys.pushToken, pushToken); - } + if (apnsToken != null) await _storeToken(apnsToken, isApns: true); + if (fcmToken != null) await _storeToken(fcmToken, isApns: false); } catch (error, stackTrace) { // The failure mode is platform-specific: on iOS an unready APNs token is // the usual cause, on Android getToken() fails at FCM registration (e.g. @@ -490,18 +487,8 @@ class NotificationService { /// /// Flat keys win where both exist. Nested JSON is parsed leniently: malformed /// or non-object content is treated as absent, never thrown on. -NotificationContent? contentFromMessage(RemoteMessage message) => - contentFromData( - message.data, - fallbackTitle: message.notification?.title, - fallbackBody: message.notification?.body, - ); - -/// The same, from a bare data map — what awesome_notifications_fcm delivers. -/// -/// [fallbackTitle] / [fallbackBody] stand in for the FCM `notification` block, -/// which only the [RemoteMessage] shape carries. A silent-data push has no such -/// block, so its text has to come from the payload itself. +/// [fallbackTitle] / [fallbackBody] stand in for an FCM `notification` block. +/// A silent-data push carries none, so its text has to come from the payload. NotificationContent? contentFromData( Map data, { String? fallbackTitle, @@ -542,7 +529,7 @@ NotificationContent? contentFromData( } /// The structured fields of a message whose producer nested them inside -/// `data['content']` as one JSON string — see [contentFromMessage]'s doc. +/// `data['content']` as one JSON string — see [contentFromData]'s doc. Map? _nestedContent(Map data) { final raw = data['content']; if (raw is! String || raw.isEmpty) return null; diff --git a/test/core/notifications/content_from_message_test.dart b/test/core/notifications/content_from_data_test.dart similarity index 66% rename from test/core/notifications/content_from_message_test.dart rename to test/core/notifications/content_from_data_test.dart index a67141eb6..b81118953 100644 --- a/test/core/notifications/content_from_message_test.dart +++ b/test/core/notifications/content_from_data_test.dart @@ -7,25 +7,26 @@ /// which is exactly how a wrong-sound report presents. library; +import 'package:awesome_notifications/awesome_notifications.dart'; import 'package:dpip/core/notifications/notification_service.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; -RemoteMessage _message( +/// The shape production hands over: a bare data map, plus the text an FCM +/// `notification` block would have carried. +NotificationContent? _content( Map data, { - RemoteNotification? notification, -}) => RemoteMessage(data: data, notification: notification); + String? title, + String? body, +}) => contentFromData(data, fallbackTitle: title, fallbackBody: body); void main() { test('the flat contract maps every field', () { - final content = contentFromMessage( - _message({ - 'channel': 'eew_alert-important-v2', - 'title': '地震速報', - 'body': '花蓮縣近海', - 'id': '42', - }), - ); + final content = _content({ + 'channel': 'eew_alert-important-v2', + 'title': '地震速報', + 'body': '花蓮縣近海', + 'id': '42', + }); expect(content?.channelKey, 'eew_alert-important-v2'); expect(content?.title, '地震速報'); @@ -37,18 +38,14 @@ void main() { // What the producer still sends: visible text in the FCM notification // block, structured fields inside data['content'] as one JSON string — // note channelKey spelled that way, and id as a number. - final content = contentFromMessage( - _message( - { - 'content': - '{"id": 305419896, "channelKey": "eq-v2", ' - '"body": "高雄市 能見度 <1 km", "notificationLayout": "BigText"}', - }, - notification: const RemoteNotification( - title: '測試通知', - body: '高雄市(國一N361K) 能見度 <1 km,請注意安全。', - ), - ), + final content = _content( + { + 'content': + '{"id": 305419896, "channelKey": "eq-v2", ' + '"body": "高雄市 能見度 <1 km", "notificationLayout": "BigText"}', + }, + title: '測試通知', + body: '高雄市(國一N361K) 能見度 <1 km,請注意安全。', ); expect(content?.channelKey, 'eq-v2', reason: 'no announcement fallback'); @@ -64,11 +61,13 @@ void main() { // routinely wider than the signed 32-bit range awesome validates. Before // the clamp this threw out of createNotification and the foreground push // simply never rendered. - final content = contentFromMessage( - _message({ + final content = _content( + { 'content': '{"id": ${0xF12345678}, "channelKey": "eq-v2", "body": "測試"}', - }, notification: const RemoteNotification(title: '地震速報', body: '花蓮縣近海')), + }, + title: '地震速報', + body: '花蓮縣近海', ); expect(content?.id, 0, reason: 'out of 32-bit range → treated as absent'); @@ -81,12 +80,14 @@ void main() { }); test('flat keys win where both shapes exist', () { - final content = contentFromMessage( - _message({ + final content = _content( + { 'channel': 'report-general-v2', 'id': '7', 'content': '{"channelKey": "announcement-general-v2", "id": 9}', - }, notification: const RemoteNotification(title: '報告', body: '內文')), + }, + title: '報告', + body: '內文', ); expect(content?.channelKey, 'report-general-v2'); @@ -94,11 +95,7 @@ void main() { }); test('malformed nested JSON degrades to the fallback, never throws', () { - final content = contentFromMessage( - _message({ - 'content': '{not json', - }, notification: const RemoteNotification(title: '公告', body: '內容')), - ); + final content = _content({'content': '{not json'}, title: '公告', body: '內容'); expect(content?.channelKey, 'announcement-general-v2'); expect(content?.title, '公告'); @@ -107,14 +104,14 @@ void main() { test('a message with nothing to show produces no content', () { // No flat keys, no nested payload, no notification block — there is // nothing to render, so asking for a channel would be meaningless. - expect(contentFromMessage(_message({})), isNull); + expect(_content(const {}), isNull); }); test('the tap payload carries the resolved channel and id', () { - final content = contentFromMessage( - _message({ - 'content': '{"channelKey": "tsunami-important-v2", "id": 5}', - }, notification: const RemoteNotification(title: '海嘯警報', body: '沿海請注意')), + final content = _content( + {'content': '{"channelKey": "tsunami-important-v2", "id": 5}'}, + title: '海嘯警報', + body: '沿海請注意', ); expect(content?.payload?['channel'], 'tsunami-important-v2'); From f3db6325f7b399f346657a3685b0cdd5da97ed5d Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 22:58:16 +0800 Subject: [PATCH 38/40] fix(weather): stop mirroring the rain backdrop on GLES devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正僅支援 OpenGL ES 的舊款 Android 裝置上,玻璃雨滴效果的背景影像上下顛倒 Fix(en-US): fix the rain-on-glass backdrop rendering upside down on Android devices limited to the OpenGLES backend --- shaders/weather/rain_on_glass.frag | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shaders/weather/rain_on_glass.frag b/shaders/weather/rain_on_glass.frag index a5dc87a6c..92ea19df8 100644 --- a/shaders/weather/rain_on_glass.frag +++ b/shaders/weather/rain_on_glass.frag @@ -160,7 +160,12 @@ void main() { // is scaled by the fixed 1080 frame, so it also translates the lattice by // `uSize.y - 1080`. Keep the two coordinates separate. vec2 texXY = xy; -#ifdef IMPELLER_TARGET_OPENGLES +#if defined(IMPELLER_TARGET_OPENGLES) && !defined(IMPELLER_OPENGLES_UNFLIPPED_DEPRECATED) + // 3.47 changed the GLES backend to store render-to-texture top-down, like + // Metal and Vulkan (docs.flutter.dev → opengles-render-to-texture-top-down). + // On those releases this flip would mirror the backdrop, so it is gated off + // by the macro that marks the new orientation; older releases without the + // macro still need it. texXY.y = uSize.y - xy.y; #endif From e5f67ff53449d1708a592abeb196d89390a644e9 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 23:01:02 +0800 Subject: [PATCH 39/40] refactor(notify): read the APNs token from iOS, not from Firebase --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/ApnsTokenPlugin.swift | 65 +++++++++ ios/Runner/AppDelegate.swift | 1 + lib/core/diagnostics/diagnostics_report.dart | 31 +---- .../notifications/notification_service.dart | 127 ++++++++++-------- pubspec.yaml | 1 - 6 files changed, 150 insertions(+), 79 deletions(-) create mode 100644 ios/Runner/ApnsTokenPlugin.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d2b212c7e..71fd913c9 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ A8D382D04B4ACD327E29F46B /* eq.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 682D0165E2FF3895C5B252C5 /* eq.aiff */; }; AA0000000000000000000C02 /* CompassPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000C01 /* CompassPlugin.swift */; }; AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000E01 /* ScreenWakePlugin.swift */; }; + AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000F01 /* ApnsTokenPlugin.swift */; }; AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000D01 /* DeviceInfoPlugin.swift */; }; AE92AD9862B7A721B0924557 /* eew.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 7A6E88CB92902C0CACB07792 /* eew.aiff */; }; CAC4EF00000000000000B001 /* MapCachePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000B002 /* MapCachePlugin.swift */; }; @@ -93,6 +94,7 @@ A382CD9DEA741E45DBF741D7 /* rain.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/rain.aiff; sourceTree = ""; }; AA0000000000000000000C01 /* CompassPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassPlugin.swift; sourceTree = ""; }; AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = ""; }; + AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = ""; }; AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = ""; }; B916667D1B2356583B174E80 /* normal.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/normal.aiff; sourceTree = ""; }; CAC4EF00000000000000B002 /* MapCachePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapCachePlugin.swift; sourceTree = ""; }; @@ -176,6 +178,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, AA0000000000000000000C01 /* CompassPlugin.swift */, AA0000000000000000000E01 /* ScreenWakePlugin.swift */, + AA0000000000000000000F01 /* ApnsTokenPlugin.swift */, AA0000000000000000000D01 /* DeviceInfoPlugin.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, @@ -370,6 +373,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, AA0000000000000000000C02 /* CompassPlugin.swift in Sources */, AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */, + AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */, AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, diff --git a/ios/Runner/ApnsTokenPlugin.swift b/ios/Runner/ApnsTokenPlugin.swift new file mode 100644 index 000000000..9fc363abb --- /dev/null +++ b/ios/Runner/ApnsTokenPlugin.swift @@ -0,0 +1,65 @@ +import Flutter +import UIKit + +/// The device's APNs token, taken from the place iOS actually hands it over. +/// +/// The backend keys every iOS registration on the raw APNs device token — not +/// Firebase's FCM registration token, which is a different string it does not +/// recognise (measured: the write 202s and every later lookup 401s, so the +/// mistake is silent). That token has exactly one source, +/// `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`; every +/// SDK that offers to "get" it is reading back what it captured from the same +/// callback. Reading it here removes the middleman, and with it the whole +/// firebase_messaging dependency that existed for this one value. +/// +/// Registration itself is not ours to trigger: `awesome_notifications` calls +/// `registerForRemoteNotifications()` once permission is granted. This only +/// listens. +public class ApnsTokenPlugin: NSObject, FlutterPlugin { + /// Set on the main thread by the delegate callback, read by the channel. + /// + /// Static because the token arrives whenever iOS decides — often before Dart + /// asks, sometimes long after — and the instance answering the channel may + /// not be the one that was registered when it landed. + private static var token: String? + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.exptech.dpip/apns_token", + binaryMessenger: registrar.messenger()) + let plugin = ApnsTokenPlugin() + registrar.addMethodCallDelegate(plugin, channel: channel) + registrar.addApplicationDelegate(plugin) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "token": + // Null until iOS has registered. Dart treats that as "not yet", not as + // failure: on a cold launch the callback usually lands a second or two + // after the engine starts. + result(ApnsTokenPlugin.token) + default: + result(FlutterMethodNotImplemented) + } + } + + public func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + // Lowercase hex, no separators — the form APNs itself uses and the one the + // backend already has on file for existing devices. + ApnsTokenPlugin.token = deviceToken.map { String(format: "%02x", $0) }.joined() + } + + public func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + // Left visible rather than swallowed: without a token this device can be + // registered by the backend but never addressed, and nothing else in the + // app would say so. + NSLog("APNs registration failed: \(error.localizedDescription)") + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4a1261707..c315634bb 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -33,6 +33,7 @@ import UserNotifications MapCachePlugin.register(with: registry.registrar(forPlugin: "MapCachePlugin")!) StorageScanPlugin.register(with: registry.registrar(forPlugin: "StorageScanPlugin")!) ScreenWakePlugin.register(with: registry.registrar(forPlugin: "ScreenWakePlugin")!) + ApnsTokenPlugin.register(with: registry.registrar(forPlugin: "ApnsTokenPlugin")!) BackgroundLocationPlugin.register( with: registry.registrar(forPlugin: "BackgroundLocationPlugin")!) BackgroundExecutionPlugin.register( diff --git a/lib/core/diagnostics/diagnostics_report.dart b/lib/core/diagnostics/diagnostics_report.dart index f18e71918..babad1739 100644 --- a/lib/core/diagnostics/diagnostics_report.dart +++ b/lib/core/diagnostics/diagnostics_report.dart @@ -15,7 +15,6 @@ library; import 'dart:io'; import 'package:dpip/core/build_info.g.dart'; -import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; import 'package:dpip/core/notifications/notification_service.dart'; @@ -26,7 +25,6 @@ import 'package:dpip/core/platform/unused_app_restrictions.dart'; import 'package:dpip/core/storage/app_database.dart'; import 'package:dpip/core/storage/app_storage_scan.dart'; import 'package:dpip/core/version/app_build.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -252,24 +250,6 @@ String _keptActive(UnusedAppRestrictions status) => switch (status) { UnusedAppRestrictions.unavailable => 'n/a', }; -Future _fcmToken() async { - try { - return await FirebaseMessaging.instance.getToken(); - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'dev: FCM token'); - return null; - } -} - -Future _apnsToken() async { - try { - return await FirebaseMessaging.instance.getAPNSToken(); - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'dev: APNs token'); - return null; - } -} - /// Reads every subsystem that can explain a support question. /// /// Takes its services rather than reaching for a locator, so a test can hand it @@ -319,10 +299,13 @@ class DiagnosticsCollector { // back to the platform build number outside a repo. final buildRef = kGitCommit == 'unknown' ? info.buildNumber : kGitCommit; // Show the platform's own push token: FCM on Android, APNs on iOS. - final fcmToken = Platform.isAndroid - ? (notifications.token ?? await _fcmToken()) - : null; - final apnsToken = Platform.isIOS ? await _apnsToken() : null; + // + // Read back from settings rather than asked for again. It is the same value + // the backend was registered with, which is the one worth seeing in a + // report — a freshly queried token that differs from the stored one would + // look reassuring and be exactly the bug. + final fcmToken = Platform.isAndroid ? notifications.token : null; + final apnsToken = Platform.isIOS ? notifications.token : null; final sections = [ ( diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 1fbf5c138..efd3ff8e2 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -12,8 +12,8 @@ import 'package:dpip/core/notifications/notification_taps.dart'; import 'package:dpip/core/notifications/plain_channels.dart'; import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; /// Fallback channel for a message with no/unknown `channel` — must be a /// registered channel or the OS rejects the notification. @@ -74,7 +74,7 @@ class NotificationService { /// Initializes channels, the tap listener, and the FCM/APNs transport. Call /// once at start-up; safe to await best-effort (a failure just means no push). Future init() async { - await _initChannels(); + final channels = await _initChannels(); await AwesomeNotifications().setListeners( onActionReceivedMethod: NotificationTaps.onActionReceived, // Kept for operational visibility, not for one bug. `created` fires when @@ -86,7 +86,35 @@ class NotificationService { onNotificationCreatedMethod: onNotificationCreated, onNotificationDisplayedMethod: onNotificationDisplayed, ); - await _initMessaging(); + await _initMessaging(channels); + } + + /// One line per launch saying whether push actually came up. + /// + /// Push is the app's reason to exist and every one of its failures is silent: + /// a channel the OS rejected, a permission never granted, a token that never + /// arrived — the app looks identical in all of them and simply never rings. + /// Printed after the token settles rather than at the end of [init], because + /// the token is fetched in the background and a line without it would say + /// "ready" before the one thing that can still be missing is known. + void _logStartup({required int channels, required int rejected}) { + final stored = token; + final kind = Platform.isIOS ? 'APNs' : 'FCM'; + final tokenText = stored == null + ? 'MISSING — this device cannot be reached' + : kDebugMode + ? '$kind $stored' + // Enough to tell two devices apart and to match against the backend, + // without putting the whole addressable identifier in a shared log. + : '$kind …${stored.substring(stored.length - 8)} (${stored.length})'; + final channelText = rejected == 0 + ? '$channels ok' + : '${channels - rejected}/$channels ok, $rejected REJECTED'; + Log.info( + 'push: ${stored == null ? 'DEGRADED' : 'ready'} · ' + '${Platform.isIOS ? 'iOS' : 'Android'} · ' + 'channels $channelText · token $tokenText', + ); } /// Requests ordinary notification permission. Call from a screen (e.g. @@ -223,7 +251,7 @@ class NotificationService { return openNotificationSettingsPage(); } - Future _initChannels() async { + Future<({int total, int rejected})> _initChannels() async { final channels = NotificationChannels.channels; // The normal path is one batch call — the same one this always made. @@ -277,7 +305,7 @@ class NotificationService { 'notifications: no channel could be registered — every one was ' 'rejected. Alerts will not be delivered.', ); - return; + return (total: channels.length, rejected: channels.length); } } @@ -348,26 +376,11 @@ class NotificationService { // channels this launch. Mirror the catalogue under plain keys last, after // every purge and re-registration above has settled. await PlainChannels.ensure(channels); + return (total: channels.length, rejected: rejected.length); } - Future _initMessaging() async { - final messaging = FirebaseMessaging.instance; - - // Tell iOS what to do with a foreground push, because the default is - // nothing. - // - // firebase_messaging's iOS delegate reads these from NSUserDefaults and, - // when the key was never written, answers the system with - // `UNNotificationPresentationOptionNone` — silence, no banner. Nothing else - // writes that key, so an app that never calls this has a foreground that is - // off by default. It costs nothing when another delegate is in front. - await messaging.setForegroundNotificationPresentationOptions( - alert: true, - badge: true, - sound: true, - ); - - // Push is awesome_notifications_fcm's job, on both platforms. + Future _initMessaging(({int total, int rejected}) channels) async { + // Push belongs to awesome_notifications_fcm, on both platforms. // // `awesome_notifications` handles local notifications only — its own source // says so: "we do not chain to a previously-installed delegate … FCM is @@ -377,28 +390,23 @@ class NotificationService { // format) and then had nothing to display it with, so the foreground went // silent and blank. // - // This is the pre-rewrite arrangement, restored. `firebase_messaging` stays - // for the APNs token below — upstream says the two must not coexist, but - // the app shipped them together for years and the token path depends on it. + // `firebase_messaging` is gone: upstream says the two must not coexist, and + // everything it was still doing here has an equivalent above — the two + // token handlers replace its refresh stream and its launch-time fetch, and + // the foreground presentation it configured is now decided by this app's + // own notification-centre delegate (see AppDelegate). await AwesomeNotificationsFcm().initialize( onFcmTokenHandle: (token) => _storeToken(token, isApns: false), onNativeTokenHandle: (token) => _storeToken(token, isApns: true), onFcmSilentDataHandle: onFcmSilentData, debug: kDebugMode, ); - - // This stream only ever carries the FCM registration token, so on iOS the - // rotation is the signal and the APNs token is what has to be re-read. - messaging.onTokenRefresh.listen((token) async { - await _storeToken(token, isApns: false); - if (Platform.isIOS) { - final apns = await messaging.getAPNSToken(); - if (apns != null) await _storeToken(apns, isApns: true); - } - }); - // Fire-and-forget: this can wait seconds for the iOS APNs token, and - // `init()` is awaited at launch, so it must not block start-up. - unawaited(_fetchToken()); + unawaited( + _fetchToken().whenComplete( + () => + _logStartup(channels: channels.total, rejected: channels.rejected), + ), + ); } /// Fetches the push token and persists it as [SettingKeys.pushToken] — @@ -444,28 +452,39 @@ class NotificationService { Log.debug('Push token stored (${isApns ? 'APNs' : 'FCM'})'); } + /// The APNs device token, read from the iOS side that receives it. + static const _apns = MethodChannel('com.exptech.dpip/apns_token'); + Future _fetchToken() async { - final messaging = FirebaseMessaging.instance; try { - String? apnsToken; - if (defaultTargetPlatform == TargetPlatform.iOS) { + if (Platform.isIOS) { + // iOS hands the token to the app delegate whenever it finishes + // registering, which is usually a moment after launch. `onNativeTokenHandle` + // delivers it too, but only if it fires — and a token that never + // arrives is silent: the backend keeps the device on file and simply + // stops being able to reach it. Polling the native side closes that + // hole without another SDK in between. for (var attempt = 0; attempt < 5; attempt++) { - apnsToken = await messaging.getAPNSToken(); - if (apnsToken != null) break; + final token = await _apns.invokeMethod('token'); + if (token != null) { + await _storeToken(token, isApns: true); + return; + } await Future.delayed(const Duration(seconds: 1)); } + Log.warning( + 'APNs token still unavailable — push cannot reach this device', + ); + return; } - final fcmToken = await messaging.getToken(); - if (apnsToken != null) await _storeToken(apnsToken, isApns: true); - if (fcmToken != null) await _storeToken(fcmToken, isApns: false); + await _storeToken( + await AwesomeNotificationsFcm().requestFirebaseAppToken(), + isApns: false, + ); } catch (error, stackTrace) { - // The failure mode is platform-specific: on iOS an unready APNs token is - // the usual cause, on Android getToken() fails at FCM registration (e.g. - // the app's signing SHA-1 not registered in the Firebase console). - final title = defaultTargetPlatform == TargetPlatform.iOS - ? 'getToken (APNs may not be ready)' - : 'getToken (FCM registration failed)'; - Log.handle(error, stackTrace, title); + // On Android this is FCM registration failing, usually the app's signing + // SHA-1 not being registered in the Firebase console. + Log.handle(error, stackTrace, 'push token'); } } } diff --git a/pubspec.yaml b/pubspec.yaml index 25894d5de..50be9eff4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,7 +29,6 @@ dependencies: # Pinned to the exact version (not `^`) so a plain `pub upgrade` can't drift # the minor either. firebase_core: 4.11.0 - firebase_messaging: 16.4.1 flutter: sdk: flutter flutter_localizations: From 34f676719a8db3fd5b14e8caac6823278357d2e3 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 24 Aug 2026 23:15:07 +0800 Subject: [PATCH 40/40] build(deps): resolve the lockfile against the current dependencies --- pubspec.lock | 52 ++++++++++++++-------------------------------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 755812d49..4ef276279 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,14 +9,6 @@ packages: url: "https://pub.dev" source: hosted version: "103.0.0" - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "78f98c1f9c4dbbd22c2bb7b7f17c4a5c06150e8b2cb791a0947979ad0d3dabd5" - url: "https://pub.dev" - source: hosted - version: "1.3.73" analyzer: dependency: transitive description: @@ -73,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.12.1" + awesome_notifications_fcm: + dependency: "direct main" + description: + name: awesome_notifications_fcm + sha256: "6b6db874792d101ee7341b49dff69acc6eae50c33a5446989bc74a14664d67f1" + url: "https://pub.dev" + source: hosted + version: "0.12.0" bluez: dependency: transitive description: @@ -312,30 +312,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.9.0" - firebase_messaging: - dependency: "direct main" - description: - name: firebase_messaging - sha256: ce21a510e5a9aed67a0404476981e19ec0361a0301eeba547dc93dc2e7dec99a - url: "https://pub.dev" - source: hosted - version: "16.4.1" - firebase_messaging_platform_interface: - dependency: transitive - description: - name: firebase_messaging_platform_interface - sha256: e10f6d521e7ed663d0ea2f4ec7de4c6729f8c2ce25d32faf6d6b4219da8515c2 - url: "https://pub.dev" - source: hosted - version: "4.9.0" - firebase_messaging_web: - dependency: transitive - description: - name: firebase_messaging_web - sha256: "7ab45dfaf8efcd1a769baa9b8debbd0da281f5d3fc07274296b564396a980292" - url: "https://pub.dev" - source: hosted - version: "4.2.1" fixnum: dependency: transitive description: @@ -736,8 +712,8 @@ packages: dependency: "direct main" description: path: maplibre_gl - ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" - resolved-ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" + ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2" + resolved-ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2" url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git version: "0.26.2" @@ -745,8 +721,8 @@ packages: dependency: "direct main" description: path: maplibre_gl_platform_interface - ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" - resolved-ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" + ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2" + resolved-ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2" url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git version: "0.26.2" @@ -754,8 +730,8 @@ packages: dependency: "direct overridden" description: path: maplibre_gl_web - ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" - resolved-ref: "61a5fd658976ec0f4be838da71bc271a3e9fc7b8" + ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2" + resolved-ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2" url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git version: "0.26.2"