diff --git a/android/app/src/main/kotlin/com/exptech/dpip/BackgroundLocationChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/BackgroundLocationChannel.kt index 3b195406d..09f70a61b 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/BackgroundLocationChannel.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/BackgroundLocationChannel.kt @@ -116,6 +116,16 @@ class BackgroundLocationChannel(private val context: Context) : }.start() } + // Off the main thread: this opens the database and vacuums it, and + // the caller re-reads the count as soon as it returns. + "clearTrack" -> { + val app = context.applicationContext + Thread { + LocationTrackStore.clear(app) + Handler(Looper.getMainLooper()).post { result.success(null) } + }.start() + } + else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt b/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt index d9fb02c6d..fd3ed9a42 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt @@ -117,6 +117,12 @@ object BgLocationStore { if (!enabled(context)) return val prefs = prefs(context) + // Before the throttle on purpose. The throttle below exists to spare the + // server a 429; the local track has no server to spare, and dropping a + // fix the OS took the trouble to deliver would put holes in the history + // for a reason that has nothing to do with it. + LocationTrackStore.record(context, lat, lng) + // At most one report a minute, across every trigger. // // Four callers fire this independently — the geofence, the alarm diff --git a/android/app/src/main/kotlin/com/exptech/dpip/LocationTrackStore.kt b/android/app/src/main/kotlin/com/exptech/dpip/LocationTrackStore.kt new file mode 100644 index 000000000..847adfb87 --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/LocationTrackStore.kt @@ -0,0 +1,213 @@ +package com.exptech.dpip + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import java.io.File + +/** + * The device's own movement history, written where the fixes arrive. + * + * The counterpart of iOS's `LocationTrackStore.swift`, and deliberately the + * same file format: one delta-encoded table, anchors every 64 rows, native + * writes, Dart reads. The two platforms disagree about almost everything in + * background location — how they wake, how often, what they are allowed to do + * — so the one thing that should not also differ is what ends up on disk. + * + * ## Why its own file + * + * Not a table in `dpip.db`. That belongs to Dart's `sqlite_async`, which keeps + * a WAL connection pool across isolates and migrates its own schema; a second + * writer here would be a cross-process multi-writer against a schema this file + * cannot see. + * + * ## The encoding + * + * Rows hold **deltas**, not absolutes. SQLite stores a small integer in one or + * two bytes and a large one in four or six, so writing the difference from the + * previous fix is compression the record format performs for free — no private + * blob to encode, and nothing for the reader to decode but addition. + * + * Every 64th row is an **anchor** holding absolute values, recognised by + * `rowid % 64 == 0` so no column is spent marking it. Eviction drops whole + * anchor groups: a delta row is meaningless without the anchor it counts from, + * and removing one from the middle would displace every position after it with + * nothing to signal that it had happened. + */ +object LocationTrackStore { + /** Degrees as ten-thousandths — about 11 m. */ + private const val SCALE = 10_000.0 + + /** Rows between absolute anchors. */ + private const val ANCHOR_EVERY = 64L + + private const val BUDGET_BYTES = 50L * 1024 * 1024 + + /** Rows between size checks; the file cannot grow meaningfully in fewer. */ + private const val CHECK_EVERY = 512L + + private const val FILE = "location_track.db" + + @Volatile private var db: SQLiteDatabase? = null + + /** + * Records one fix. + * + * Never throws into the caller: this runs inside a JobService or an alarm + * receiver whose real work is reporting the position, and a storage failure + * must not take that down with it. + */ + @Synchronized + fun record(context: Context, lat: Double, lng: Double, atMillis: Long = System.currentTimeMillis()) { + val handle = open(context) ?: return + try { + val t = atMillis / 1000 + val latE4 = Math.round(lat * SCALE) + val lngE4 = Math.round(lng * SCALE) + + var rowid = lastRowId(handle) + 1 + val previous = if (rowid % ANCHOR_EVERY == 0L) null else lastAbsolute(handle) + + // A row that cannot be a delta has to be an anchor, and an anchor is + // recognised by its rowid alone — so move the row to the next + // boundary rather than write an absolute where a reader expects a + // delta. Happens on the first fix, and on the first after eviction + // removed the tail. + if (previous == null && rowid % ANCHOR_EVERY != 0L) { + rowid += ANCHOR_EVERY - rowid % ANCHOR_EVERY + } + + handle.execSQL( + "INSERT INTO fix (id, t, lat, lng) VALUES (?, ?, ?, ?)", + arrayOf( + rowid, + previous?.let { t - it.t } ?: t, + previous?.let { latE4 - it.lat } ?: latE4, + previous?.let { lngE4 - it.lng } ?: lngE4, + ), + ) + evictIfNeeded(handle, rowid) + } catch (_: Throwable) { + // Deliberately swallowed, and deliberately not logged: this path can + // run hundreds of times a day in the background, and a log line per + // failure would be the only trace of a disk that is simply full. + // The size is visible through the app's storage screen instead. + } + } + + /** + * Deletes every recorded fix and hands the pages back to the filesystem. + * + * Through the open handle, never by deleting the file: this object caches + * the handle, and one whose file was removed underneath it goes on writing + * into an unlinked inode — the rows reappear on the next read, the space is + * never returned, and nothing reports a problem. + */ + @Synchronized + fun clear(context: Context): Boolean { + val handle = open(context) ?: return false + return try { + handle.execSQL("DELETE FROM fix") + // Only returns bytes because `auto_vacuum=INCREMENTAL` was set + // before the table existed — see [open]. + handle.execSQL("PRAGMA incremental_vacuum") + true + } catch (_: Throwable) { + false + } + } + + private fun open(context: Context): SQLiteDatabase? { + db?.let { if (it.isOpen) return it } + return try { + // filesDir, not noBackupFilesDir: the manifest already sets + // android:allowBackup="false", and this is the directory Dart's + // getApplicationSupportDirectory() resolves to — the reader should + // not have to guess where the writer put it. + val file = File(context.filesDir, FILE) + val handle = SQLiteDatabase.openOrCreateDatabase(file, null) + // Before the table exists: set afterwards it is a no-op until a full + // VACUUM, and without it deleted rows free pages inside the file and + // never give the bytes back to the budget. + handle.execSQL("PRAGMA auto_vacuum=INCREMENTAL") + handle.execSQL("PRAGMA journal_mode=WAL") + // A fix lost to a power cut is one point on a track; a blocked fsync + // inside a background wake can cost the report as well. + handle.execSQL("PRAGMA synchronous=NORMAL") + handle.execSQL( + """ + CREATE TABLE IF NOT EXISTS fix ( + id INTEGER PRIMARY KEY, + t INTEGER NOT NULL, + lat INTEGER NOT NULL, + lng INTEGER NOT NULL + ) + """.trimIndent(), + ) + db = handle + handle + } catch (_: Throwable) { + null + } + } + + private data class Fix(val t: Long, val lat: Long, val lng: Long) + + private fun scalar(handle: SQLiteDatabase, sql: String): Long = + handle.rawQuery(sql, null).use { if (it.moveToFirst()) it.getLong(0) else 0L } + + private fun lastRowId(handle: SQLiteDatabase): Long = + scalar(handle, "SELECT IFNULL(MAX(id), 0) FROM fix") + + /** + * The absolute position of the last row, rebuilt from its anchor forward. + * + * Bounded by [ANCHOR_EVERY], so at most 64 rows of addition — cheaper than a + * cached copy that a crash or a second process could leave stale. + */ + private fun lastAbsolute(handle: SQLiteDatabase): Fix? { + val last = lastRowId(handle) + if (last == 0L) return null + val anchor = last - last % ANCHOR_EVERY + var current: Fix? = null + handle.rawQuery( + "SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id", + arrayOf(anchor.toString()), + ).use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong(0) + val t = cursor.getLong(1) + val lat = cursor.getLong(2) + val lng = cursor.getLong(3) + current = if (id % ANCHOR_EVERY == 0L || current == null) { + Fix(t, lat, lng) + } else { + current!!.let { Fix(it.t + t, it.lat + lat, it.lng + lng) } + } + } + } + return current + } + + /** Drops the oldest anchor groups until the file is back inside its budget. */ + private fun evictIfNeeded(handle: SQLiteDatabase, rowid: Long) { + if (rowid % CHECK_EVERY != 0L) return + val pageSize = scalar(handle, "PRAGMA page_size") + var bytes = pageSize * scalar(handle, "PRAGMA page_count") + if (bytes <= BUDGET_BYTES) return + + // Free about a tenth at a time. Trimming to exactly the limit would put + // the next fix straight back over it, and the vacuum is the expensive + // part of this path. + val target = BUDGET_BYTES - BUDGET_BYTES / 10 + var oldest = scalar(handle, "SELECT IFNULL(MIN(id), 0) FROM fix") + while (bytes > target && oldest > 0) { + val boundary = oldest + ANCHOR_EVERY - oldest % ANCHOR_EVERY + handle.execSQL("DELETE FROM fix WHERE id < $boundary") + handle.execSQL("PRAGMA incremental_vacuum") + bytes = pageSize * scalar(handle, "PRAGMA page_count") + val next = scalar(handle, "SELECT IFNULL(MIN(id), 0) FROM fix") + if (next <= oldest) break + oldest = next + } + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 71fd913c9..a4738f9d6 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 */; }; + AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000G01 /* LocationTrackStore.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 */; }; @@ -94,6 +95,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 = ""; }; + AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.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 = ""; }; @@ -178,6 +180,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, AA0000000000000000000C01 /* CompassPlugin.swift */, AA0000000000000000000E01 /* ScreenWakePlugin.swift */, + AA0000000000000000000G01 /* LocationTrackStore.swift */, AA0000000000000000000F01 /* ApnsTokenPlugin.swift */, AA0000000000000000000D01 /* DeviceInfoPlugin.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, @@ -373,6 +376,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, AA0000000000000000000C02 /* CompassPlugin.swift in Sources */, AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */, + AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */, AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */, AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, diff --git a/ios/Runner/BackgroundLocationPlugin.swift b/ios/Runner/BackgroundLocationPlugin.swift index de3ac2ac1..ca211cdc0 100644 --- a/ios/Runner/BackgroundLocationPlugin.swift +++ b/ios/Runner/BackgroundLocationPlugin.swift @@ -91,6 +91,13 @@ public class BackgroundLocationPlugin: NSObject, FlutterPlugin, CLLocationManage result(nil) case "diagnostics": result(diagnostics()) + // Answered only once the delete has run. The developer page re-reads the + // fix count the moment this returns, and replying early would show it the + // count it just asked to throw away. + case "clearTrack": + LocationTrackStore.shared.clear { + DispatchQueue.main.async { result(nil) } + } default: result(FlutterMethodNotImplemented) } @@ -221,6 +228,13 @@ public class BackgroundLocationPlugin: NSObject, FlutterPlugin, CLLocationManage return } recenterRegion(coordinate) + // Before the distance gate on purpose. `shouldReport` exists to spare the + // server, and the local track has no server to spare — dropping a fix the + // OS took the trouble to deliver, because a report would have been too + // soon, would put holes in the history for a reason that has nothing to do + // with it. + LocationTrackStore.shared.record( + latitude: coordinate.latitude, longitude: coordinate.longitude) guard shouldReport(coordinate) else { return } report(coordinate) defaults.set(coordinate.latitude, forKey: Self.lastLatKey) diff --git a/ios/Runner/LocationTrackStore.swift b/ios/Runner/LocationTrackStore.swift new file mode 100644 index 000000000..8cded6eea --- /dev/null +++ b/ios/Runner/LocationTrackStore.swift @@ -0,0 +1,265 @@ +import Foundation +import SQLite3 + +/// The device's own movement history, written where the fixes arrive. +/// +/// Background location wakes the app with the screen off and Dart not running, +/// so the track has to be written by the same native code that already reports +/// the fix. Dart never writes here — it opens this file read-only — and the +/// eviction that keeps it under budget is native too. One owner for the format, +/// one owner for the size. +/// +/// ## Why its own file +/// +/// Not a table in `dpip.db`. That database belongs to Dart's `sqlite_async`, +/// which keeps a WAL connection pool across isolates; a second writer in +/// another process would be a cross-process multi-writer, and its schema is +/// migrated by code this class cannot see. A separate file makes both problems +/// disappear and costs one file handle. +/// +/// ## The encoding +/// +/// Rows hold **deltas**, not absolutes. SQLite already stores small integers in +/// one or two bytes and large ones in four or six, so a delta of `+3` costs a +/// byte where an absolute latitude costs four — the compression is the record +/// format's, and nothing has to encode or decode a private blob. +/// +/// Every 64th row is an **anchor**: absolute values, recognised by +/// `rowid % 64 == 0`, so no column is spent marking it. Eviction removes whole +/// anchor groups, which is what keeps the chain readable after the head is +/// gone — deleting a single delta row would silently displace everything after +/// it. +/// +/// anchor t(4) + lat(3) + lng(3) + header ≈ 14 B +/// delta t(1) + lat(1) + lng(1) + header ≈ 8 B +/// +/// Measured at 14.19 bytes a row over 200,000 rows, rowid and B-tree included, +/// so the budget below holds about 3.7 million fixes. Significant-change +/// delivers tens to low hundreds a day, which is a century of them; the budget +/// is there for the pathological case, not the expected one. +final class LocationTrackStore { + static let shared = LocationTrackStore() + + /// Degrees are stored as ten-thousandths: about 11 m, and the precision the + /// caller asked for. + private static let scale = 10_000.0 + + /// Rows between absolute anchors. A power of two so the modulo is cheap and + /// the boundary is obvious in a hex dump. + private static let anchorEvery: Int64 = 64 + + private static let budgetBytes: Int64 = 50 * 1024 * 1024 + + /// How often to bother checking the size. The check is a `pragma` pair, but + /// it runs inside a background wake window measured in seconds, and the file + /// cannot grow by a meaningful fraction of 50 MB between two fixes. + private static let checkEvery: Int64 = 512 + + private var db: OpaquePointer? + private let queue = DispatchQueue(label: "com.exptech.dpip.location-track") + + private init() {} + + /// Records one fix. Safe to call from any thread; never throws into the + /// caller, because the caller is a location callback whose failure would take + /// the reporting path down with it. + func record(latitude: Double, longitude: Double, at time: Date = Date()) { + queue.async { [weak self] in + guard let self, let db = self.open() else { return } + let t = Int64(time.timeIntervalSince1970) + let lat = Int64((latitude * Self.scale).rounded()) + let lng = Int64((longitude * Self.scale).rounded()) + + var rowid = self.lastRowId(db) + 1 + let previous = rowid % Self.anchorEvery == 0 ? nil : self.lastAbsolute(db) + + // A row that cannot be a delta has to be an anchor, and an anchor is + // recognised by its rowid alone — so move the row to the next boundary + // rather than writing an absolute value where a reader expects a delta. + // Happens exactly twice: on the first fix, and on the first after the + // tail was evicted. + if previous == nil, rowid % Self.anchorEvery != 0 { + rowid += Self.anchorEvery - rowid % Self.anchorEvery + } + + self.insert( + db, rowid: rowid, + t: previous.map { t - $0.t } ?? t, + lat: previous.map { lat - $0.lat } ?? lat, + lng: previous.map { lng - $0.lng } ?? lng) + self.evictIfNeeded(db) + } + } + + /// Deletes every recorded fix and hands the pages back to the filesystem. + /// + /// Through the open handle on the store's own queue, never by unlinking the + /// file. This class caches `db` for the life of the process, and a handle + /// whose file was removed underneath it goes on writing perfectly happily + /// into an unlinked inode: the rows reappear the moment anything reads, the + /// space is never returned, and nothing anywhere reports a problem. + /// + /// [completion] fires on the queue once the delete has actually run, so a + /// caller that re-reads the count sees the cleared store rather than the one + /// it asked to clear. + func clear(completion: (() -> Void)? = nil) { + queue.async { [weak self] in + guard let self, let db = self.open() else { + completion?() + return + } + self.exec(db, "DELETE FROM fix") + // Only gives bytes back because `auto_vacuum=INCREMENTAL` was set before + // the table existed — see `open()`. Without that this deletes rows and + // leaves the file exactly as large as it was. + self.exec(db, "PRAGMA incremental_vacuum") + completion?() + } + } + + // MARK: - storage + + private func open() -> OpaquePointer? { + if let db { return db } + guard let path = Self.path() else { return nil } + var handle: OpaquePointer? + guard sqlite3_open_v2( + path, &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil + ) == SQLITE_OK else { + sqlite3_close(handle) + return nil + } + // Explicit, not inherited. The default protection class would let these + // writes work today and stop working the day someone raises the app's + // default to `complete` — and every one of these writes happens with the + // screen off, which is exactly when that class denies access. + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: path) + + // Before the table exists: setting it afterwards is a no-op until a full + // VACUUM, and without it the file only ever grows — deleting rows would + // free pages inside the file and never give the bytes back to the budget. + exec(handle, "PRAGMA auto_vacuum=INCREMENTAL") + exec(handle, "PRAGMA journal_mode=WAL") + // Durability is worth less here than surviving the wake window: a fix lost + // to a power cut is one point on a track, while a blocked fsync inside a + // ten-second background window can cost the report as well. + exec(handle, "PRAGMA synchronous=NORMAL") + exec(handle, """ + CREATE TABLE IF NOT EXISTS fix ( + id INTEGER PRIMARY KEY, + t INTEGER NOT NULL, + lat INTEGER NOT NULL, + lng INTEGER NOT NULL + ) + """) + db = handle + return handle + } + + private static func path() -> String? { + // Application Support, beside the app's other databases, and excluded from + // backups: a movement history is regenerable and does not belong in a + // restore of a different device. + guard var url = try? FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + else { return nil } + url.appendPathComponent("location_track.db") + var resource = URLResourceValues() + resource.isExcludedFromBackup = true + var mutable = url + try? mutable.setResourceValues(resource) + return url.path + } + + private func exec(_ db: OpaquePointer?, _ sql: String) { + sqlite3_exec(db, sql, nil, nil, nil) + } + + private func lastRowId(_ db: OpaquePointer) -> Int64 { + scalar(db, "SELECT IFNULL(MAX(id), 0) FROM fix") ?? 0 + } + + /// The absolute position of the last row, rebuilt from its anchor forward. + /// + /// Walking the group is bounded by [anchorEvery], so this is at most 64 rows + /// of arithmetic — cheaper than keeping a cached copy that a second process + /// or a crash could leave stale. + private func lastAbsolute(_ db: OpaquePointer) -> (t: Int64, lat: Int64, lng: Int64)? { + let last = lastRowId(db) + guard last > 0 else { return nil } + let anchor = last - (last % Self.anchorEvery) + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + db, "SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id", -1, &statement, nil + ) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + sqlite3_bind_int64(statement, 1, anchor) + + var current: (t: Int64, lat: Int64, lng: Int64)? + while sqlite3_step(statement) == SQLITE_ROW { + let id = sqlite3_column_int64(statement, 0) + let t = sqlite3_column_int64(statement, 1) + let lat = sqlite3_column_int64(statement, 2) + let lng = sqlite3_column_int64(statement, 3) + if id % Self.anchorEvery == 0 || current == nil { + current = (t, lat, lng) + } else if let previous = current { + current = (previous.t + t, previous.lat + lat, previous.lng + lng) + } + } + return current + } + + private func insert(_ db: OpaquePointer, rowid: Int64, t: Int64, lat: Int64, lng: Int64) { + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + db, "INSERT INTO fix (id, t, lat, lng) VALUES (?, ?, ?, ?)", -1, &statement, nil + ) == SQLITE_OK else { return } + defer { sqlite3_finalize(statement) } + sqlite3_bind_int64(statement, 1, rowid) + sqlite3_bind_int64(statement, 2, t) + sqlite3_bind_int64(statement, 3, lat) + sqlite3_bind_int64(statement, 4, lng) + sqlite3_step(statement) + } + + private func scalar(_ db: OpaquePointer, _ sql: String) -> Int64? { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + return sqlite3_step(statement) == SQLITE_ROW ? sqlite3_column_int64(statement, 0) : nil + } + + /// Drops the oldest anchor groups until the file is back inside its budget. + /// + /// Whole groups, never single rows: a delta row is meaningless without the + /// anchor it counts from, so removing one row from the middle would shift + /// every position after it without any way to notice. + private func evictIfNeeded(_ db: OpaquePointer) { + let last = lastRowId(db) + guard last % Self.checkEvery == 0 else { return } + guard let pageSize = scalar(db, "PRAGMA page_size"), + let pageCount = scalar(db, "PRAGMA page_count") + else { return } + var bytes = pageSize * pageCount + guard bytes > Self.budgetBytes else { return } + + // Free about a tenth of the budget at a time. Trimming to exactly the limit + // would put the next fix straight back over it, and the VACUUM below is the + // expensive part of this whole path. + let target = Self.budgetBytes - Self.budgetBytes / 10 + var oldest = scalar(db, "SELECT IFNULL(MIN(id), 0) FROM fix") ?? 0 + while bytes > target, oldest > 0 { + let boundary = oldest + Self.anchorEvery - (oldest % Self.anchorEvery) + exec(db, "DELETE FROM fix WHERE id < \(boundary)") + exec(db, "PRAGMA incremental_vacuum") + guard let count = scalar(db, "PRAGMA page_count") else { break } + bytes = pageSize * count + guard let next = scalar(db, "SELECT IFNULL(MIN(id), 0) FROM fix"), next > oldest else { break } + oldest = next + } + } +} diff --git a/lib/app/app.dart b/lib/app/app.dart index 4f71a36c1..a88bb6ba5 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -175,10 +175,9 @@ class _AppServicesHostState extends State<_AppServicesHost> NotificationTaps.onTap = routeNotificationTap; widget.onboarding.addListener(_onOnboardingChanged); WidgetsBinding.instance.addPostFrameCallback((_) { - Log.debug( - 'first frame rendered ${Log.sinceStart.elapsedMilliseconds} ms ' - 'after start', - ); + // INFO, not DEBUG: this is the number anyone asking "why is launch slow" + // needs, and a release log keeps DEBUG out. + Log.info('first frame rendered ${Log.sinceStartMs} ms after start'); // Spread the first polls so the post-first-frame burst doesn't hammer // the network and UI isolate at once (EEW leads; the rest follow). widget.realtimeService.startAll( diff --git a/lib/app/router/notification_routes.dart b/lib/app/router/notification_routes.dart index e41b723a4..e983d7111 100644 --- a/lib/app/router/notification_routes.dart +++ b/lib/app/router/notification_routes.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + import 'package:dpip/app/router/app_router.dart'; import 'package:dpip/core/logging/log.dart'; -import 'package:dpip/core/notifications/notification_channels.dart'; import 'package:dpip/core/notifications/notification_tap.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Opens a URL outside the app. Injectable so tests never reach the browser. +typedef NotificationUrlLauncher = Future Function(Uri url); /// The slice of the router a notification tap needs — [GoRouter.goNamed]. typedef NotificationRouteNavigator = void Function( @@ -13,44 +18,188 @@ typedef NotificationRouteNavigator = void Function( Object? extra, }); -/// Single owner of notification → destination, mirroring the legacy -/// `notify.dart` tap table in one file: [NotificationTaps] carries the tap -/// intent and calls [routeNotificationTap] once the router is live (replaying a -/// cold-start tap through [NotificationTaps.drainPending]). The channel -/// resolves to a route name via the declarative group table below, then the -/// router navigates — no widget hosts this logic, so adding an alert family is -/// one row in the table and nothing else. +/// Single owner of notification → destination: [NotificationTaps] carries the +/// tap intent and calls [routeNotificationTap] once the router is live +/// (replaying a cold-start tap through `NotificationTaps.drainPending`). No +/// widget hosts this logic. /// /// [navigate] is injectable for tests; it defaults to the app router's /// [GoRouter.goNamed]. void routeNotificationTap( NotificationTap tap, { NotificationRouteNavigator? navigate, + NotificationUrlLauncher? launch, }) { - (navigate ?? appRouter.goNamed)(routeForNotificationChannel(tap.channelKey)); + final go = navigate ?? appRouter.goNamed; + Log.info( + 'Notification route: channel=${tap.channelKey} ' + 'target=${tap.data[notificationTargetKey]} keys=${tap.data.keys.toList()}', + ); + final url = notificationChannelUrls[tap.channelKey]; + if (url != null) { + unawaited(_openExternally(tap, url, go, launch ?? _launch)); + return; + } + final detail = detailFor(tap); + if (detail != null) { + Log.info( + 'Notification tap: channel=${tap.channelKey} -> ${detail.name} ' + '${detail.pathParameters}', + ); + go(detail.name, pathParameters: detail.pathParameters); + return; + } + final route = routeForNotificationChannel(tap.channelKey); + // Says *why* it is the list rather than an item, because "went to the list" + // is what both a channel with no detail route and a missing target look like. + final reason = notificationChannelDetailRoutes.containsKey(tap.channelKey) + ? 'no $notificationTargetKey in payload' + : 'channel has no detail route'; + Log.info( + 'Notification tap: channel=${tap.channelKey} -> route=$route ($reason)', + ); + go(route); +} + +/// Channels whose payload can name one specific item, and the route that shows +/// it. The item's identifier travels under [notificationTargetKey]. +/// +/// Only the earthquake-report channels do this today. The push producer sends +/// the report id — a string like `115058-2026-0827-054720` — and the tap opens +/// that report rather than the list it sits in. +/// +/// Deliberately **not** the notification's own `id`. That one is +/// awesome_notifications' 32-bit replace/dedupe handle, and a report id is a +/// long string; sharing the key would have the report id truncated into a +/// notification id, or the whole notification dropped for being out of range. +const Map notificationChannelDetailRoutes = { + 'report-general-v2': AppRoutes.earthquakeReport, + 'report-silence-v2': AppRoutes.earthquakeReport, +}; + +/// The payload key naming the item a tap should open. +const String notificationTargetKey = 'reportId'; + +/// The specific-item destination for [tap], or null to fall back to the list. +/// +/// Null whenever anything is missing — an older producer that sends no target, +/// an empty string, a channel with no detail route. A notification that says +/// only "a report arrived" is still worth opening; it just opens the list. +({String name, Map pathParameters})? detailFor( + NotificationTap tap, +) { + final route = notificationChannelDetailRoutes[tap.channelKey]; + if (route == null) return null; + final target = tap.data[notificationTargetKey]; + if (target == null || target.isEmpty) return null; + // The route's path is `:id` — see `earthquakeReportPath`. + return (name: route, pathParameters: {'id': target}); +} + +Future _launch(Uri url) => + launchUrl(url, mode: LaunchMode.externalApplication); + +/// Sends the tap to the browser, falling back into the app if it will not go. +/// +/// The fallback is the point. A device with no browser, a URL the OS refuses, +/// a launcher that throws mid-cold-start — any of those would otherwise leave +/// the tap doing nothing at all, which is indistinguishable from the app +/// having ignored it. The channel keeps its row in [notificationChannelRoutes] +/// precisely so there is somewhere to land. +Future _openExternally( + NotificationTap tap, + String url, + NotificationRouteNavigator go, + NotificationUrlLauncher launch, +) async { + Log.info('Notification tap: channel=${tap.channelKey} -> url=$url'); + var opened = false; + try { + opened = await launch(Uri.parse(url)); + } catch (error) { + Log.warning('Notification tap: could not open $url — $error'); + } + if (opened) return; + final route = routeForNotificationChannel(tap.channelKey); + Log.warning('Notification tap: $url did not open, falling back to $route'); + go(route); } +/// Channels whose tap belongs outside the app. +/// +/// A tap here opens the browser instead of navigating, and the entry wins over +/// [notificationChannelRoutes] — which still carries a row for the same +/// channel, as the fallback for when the browser will not open. Two tables +/// rather than one destination union: the union would be the tidier type, but +/// every channel would then have to say which kind it is, to express something +/// exactly one channel does. +const Map notificationChannelUrls = { + // 公告 lives on the web and has no in-app screen. Home is its fallback. + 'announcement-general-v2': 'https://announcement.exptech.com.tw/', +}; + +/// Every channel's destination, one row each. +/// +/// Keyed by `channelKey` rather than derived from the channel's group. The +/// group was the shorter table — six rows instead of twenty-four — and it +/// carried a property this one does not: a newly declared channel routed +/// correctly on the strength of its group, with no edit here. Routing per +/// channel buys the ability to send two channels in the same group to +/// different screens, and pays for it by making every new channel a row that +/// somebody has to remember. +/// +/// Nobody has to remember: `notification_routes_test.dart` walks +/// [NotificationChannels.channels] and fails on the first key missing from this +/// map. A forgotten row is a red test, not a tap that quietly lands on Home. +/// +/// Grouped by subject for reading only — the lookup is exact, so order and +/// grouping carry no meaning and no prefix can shadow another. +const Map notificationChannelRoutes = { + // 地震速報 — the live monitor, where the countdown and the shaking are. + 'eew_alert-important-v2': AppRoutes.eew, + 'eew_alert-general-v2': AppRoutes.eew, + 'eew_alert-silent-v2': AppRoutes.eew, + 'eew-important-v2': AppRoutes.eew, + 'eew-general-v2': AppRoutes.eew, + 'eew-silence-v2': AppRoutes.eew, + 'eq-v2': AppRoutes.eew, + 'int_report-general-v2': AppRoutes.eew, // 需要 ID + 'int_report-silence-v2': AppRoutes.eew, // 需要 ID + // 地震 — the report list. Detail-by-id comes later; the tap already carries + // the id, so that is a change to `routeNotificationTap`, not to this table. + 'report-general-v2': AppRoutes.earthquake, // 需要 ID + 'report-silence-v2': AppRoutes.earthquake, // 需要 ID + // 天氣 — no dedicated screen yet, so Home, which surfaces active events. + 'thunderstorm-important-v2': AppRoutes.home, // 需要 ID + 'thunderstorm-general-v2': AppRoutes.home, // 需要 ID + 'weather_major-important-v2': AppRoutes.home, // 需要 ID + 'weather_minor-general-v2': AppRoutes.home, // 需要 ID + 'evacuation_major-important-v2': AppRoutes.home, // 需要 ID + 'evacuation_minor-general-v2': AppRoutes.home, // 需要 ID + // 海嘯 — same, until a tsunami screen exists. + 'tsunami-important-v2': AppRoutes.home, // 需要 ID + 'tsunami-general-v2': AppRoutes.home, // 需要 ID + 'tsunami-silent-v2': AppRoutes.home, // 需要 ID + // LoRa 網狀網路 + 'mesh_message': AppRoutes.meshtastic, + 'mesh_node': AppRoutes.meshtastic, + + // 其他 + 'announcement-general-v2': AppRoutes.home, + + // Not an alert and not a navigation target: the silent service channel for + // background work. It is here so that it resolves without logging the + // "unmapped" warning every time something inspects it. + 'background': AppRoutes.home, +}; + /// Resolves a tapped notification's channel to a destination route. /// -/// Declarative and group-driven (via [NotificationChannels.groupOf]) instead of -/// a hand-ordered `startsWith` chain: a new channel routes by its group with no -/// change here, and an unmapped one is logged, not silently sent Home. Detail -/// routes (a specific report/event by id) come later; this picks the tab and the -/// tap already carries the id. +/// An exact lookup in [notificationChannelRoutes]. An unknown key is logged and +/// sent Home — a tap must always land somewhere, but never silently. String routeForNotificationChannel(String? channelKey) { if (channelKey == null) return _unmapped(channelKey); - - // The only intra-group split: report detail lands on the report list for now, - // while EEW taps open the live monitor. - if (channelKey.startsWith('report')) return AppRoutes.earthquake; - - return switch (NotificationChannels.groupOf(channelKey)) { - 'group_eew' => AppRoutes.eew, - 'group_eq' => AppRoutes.earthquake, - 'group_mesh' => AppRoutes.meshtastic, - 'group_info' || 'group_tsunami' || 'group_other' => AppRoutes.home, - _ => _unmapped(channelKey), - }; + return notificationChannelRoutes[channelKey] ?? _unmapped(channelKey); } String _unmapped(String? channelKey) { diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index ddbd38d2b..5aaf747f5 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -160,6 +160,8 @@ void _refuseUnlessLaunchedByTool() { } Future bootstrap() async { + // First statement, and it has to stay first — see [Log.startClock]. + Log.startClock(); WidgetsFlutterBinding.ensureInitialized(); Log.installErrorHandlers(); @@ -200,10 +202,12 @@ Future bootstrap() async { final appVersionFuture = AppBuild.ensureLoaded().then((_) => AppBuild.label); final durable = await durableFuture; + final durableMs = Log.sinceStartMs; final settings = await SettingsStore.open(durable); Log.info( 'settings ready durable=${durable != null} keys=${settings.keys.length} ' - 'onboarding=${settings.getBool(SettingKeys.onboardingComplete)}', + 'onboarding=${settings.getBool(SettingKeys.onboardingComplete)} ' + 'durable=${durableMs}ms settings=${Log.sinceStartMs - durableMs}ms', ); final onboarding = OnboardingStore(settings); // A launch that could not open the database must not spend the whole session @@ -228,7 +232,9 @@ Future bootstrap() async { final mapLayerOrder = MapLayerOrderController(settings); final mapLayerVisibility = MapLayerVisibilityController(settings); final mapReferenceOutline = MapReferenceOutlineController(settings); + final cacheStart = Log.sinceStartMs; final cache = await cacheFuture; + final cacheMs = Log.sinceStartMs - cacheStart; final dio = createDio(etagCache: cache?.etag, usage: cache?.usage); final endpointHealth = EndpointHealthMonitor(); final apiClient = ApiClient(dio, regions, endpointHealth); @@ -237,7 +243,9 @@ Future bootstrap() async { final mapTileCache = cache == null ? null : MapTileCache(cache.etag, usage: cache.usage); + final tileStart = Log.sinceStartMs; await mapTileCache?.install(); + final tileMs = Log.sinceStartMs - tileStart; // Turn off the OS-level disk HTTP cache (iOS NSURLCache) and drop its // residue: every cached byte now lives in the app's own SQLite, so a second // disk copy is pure overhead. Fire-and-forget: it never delays launch. @@ -275,7 +283,9 @@ Future bootstrap() async { // the nearest-centroid fallback; the boundary polygons back exact // point-in-polygon GPS resolution and decode in a background isolate (see // `TownBoundaries.load`) so they never delay launch or the first frames. + final townStart = Log.sinceStartMs; final townDirectory = await townDirectoryFuture; + final townMs = Log.sinceStartMs - townStart; final regionStore = RegionStore(settings); final locationService = LocationService( townDirectory, @@ -287,7 +297,9 @@ Future bootstrap() async { // after the first frame once GPS permission is granted; a null token (not yet // registered) simply skips — it self-heals on the next move. final locationApi = LocationApi(apiClient); + final versionStart = Log.sinceStartMs; final appVersion = await appVersionFuture; + final versionMs = Log.sinceStartMs - versionStart; final reportPlatform = Platform.isIOS ? 1 : 0; final deviceLocationReporter = DeviceLocationReporter( positions: () => locationService.positionStream(), @@ -409,7 +421,15 @@ Future bootstrap() async { // Each feature turns [deps] into its providers (and registers its realtime // channels). Adding a feature = one line here + its `*Providers` function. - Log.info('bootstrap ready in ${Log.sinceStart.elapsedMilliseconds} ms'); + // Every await that sits between launch and the first frame, so the next + // person to call this slow knows which one to look at. The five run + // concurrently, so they do not sum to the total — each is the time still + // left to wait when its turn came. + Log.info( + 'bootstrap ready in ${Log.sinceStartMs} ms ' + '(durable+settings ${durableMs}ms, cache ${cacheMs}ms, ' + 'tiles ${tileMs}ms, towns ${townMs}ms, version ${versionMs}ms)', + ); runApp( DpipApp( deps: deps, diff --git a/lib/core/diagnostics/diagnostics_report.dart b/lib/core/diagnostics/diagnostics_report.dart index babad1739..482b0d700 100644 --- a/lib/core/diagnostics/diagnostics_report.dart +++ b/lib/core/diagnostics/diagnostics_report.dart @@ -14,6 +14,7 @@ library; import 'dart:io'; +import 'package:dpip/core/geo/location_track.dart'; import 'package:dpip/core/build_info.g.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; @@ -289,6 +290,12 @@ class DiagnosticsCollector { final tables = await database.tableStats(); final device = await DeviceInfoService.load(); final bgLocation = await backgroundLocation.diagnostics(); + // Read-only, and absent on a device that has never recorded one — a fresh + // install and a denied background grant both land here, and neither is an + // error worth a row that says so. + final track = await LocationTrack.open(); + final trackStats = await track?.stats(); + await track?.close(); // The two states that silently end background reporting while every // permission above still reads "granted" — and the two that were missing // from the dump people paste when asking why they got no alert. @@ -382,6 +389,14 @@ class DiagnosticsCollector { // different fixes, and a single last-report row cannot tell them // apart — it says `never` for both. (label: 'Wakes', value: _wakes(bgLocation)), + // Fixes and bytes together: the ratio is the compression working or + // not, and a count that climbs while the file does not (or the other + // way round) is the first sign the delta chain has gone wrong. + if (trackStats != null) + ( + label: 'Track fixes', + value: '${trackStats.fixes} · ${formatBytes(trackStats.bytes)}', + ), if (bgLocation['lastGeofenceError'] != null) ( label: 'Geofence error', diff --git a/lib/core/error/failure.dart b/lib/core/error/failure.dart index 6c9317489..a81a4a2b2 100644 --- a/lib/core/error/failure.dart +++ b/lib/core/error/failure.dart @@ -32,6 +32,17 @@ final class NoDataFailure extends Failure { const NoDataFailure(super.message); } +/// The thing asked for does not exist (404). +/// +/// Its own type because the recovery is the opposite of every other network +/// failure's. A 500 or a timeout is transient — the right response is a retry +/// button. A 404 is permanent: the report was withdrawn, or the id in a +/// notification payload never named anything. Retrying that forever is a +/// button that cannot work, so callers send the user somewhere real instead. +final class NotFoundFailure extends Failure { + const NotFoundFailure(super.message); +} + /// An unexpected, unclassified failure. final class UnexpectedFailure extends Failure { const UnexpectedFailure(super.message); diff --git a/lib/core/geo/location_track.dart b/lib/core/geo/location_track.dart new file mode 100644 index 000000000..9c60bcafc --- /dev/null +++ b/lib/core/geo/location_track.dart @@ -0,0 +1,250 @@ +/// Read access to the movement history the native side records. +library; + +import 'dart:io'; + +import 'package:dpip/core/logging/log.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:path_provider/path_provider.dart'; +import 'package:sqlite3/sqlite3.dart' show Database; +import 'package:sqlite_async/native.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +/// One recorded position. +class TrackFix { + const TrackFix({ + required this.time, + required this.latitude, + required this.longitude, + }); + + final DateTime time; + + /// Degrees, to four places — about 11 m, which is the precision the store + /// keeps. Reading back more decimals than that would be inventing them. + final double latitude; + final double longitude; + + @override + String toString() => + 'TrackFix(${time.toIso8601String()}, $latitude, $longitude)'; +} + +/// Reads the track that iOS's `LocationTrackStore.swift` and Android's +/// `LocationTrackStore.kt` write. +/// +/// **Read-only, and that is the whole design.** Fixes arrive while the app is +/// backgrounded and Dart is not running, so the native side owns the writes; +/// it owns the eviction too, because a 50 MB budget enforced from two places +/// is a budget enforced from neither. The connection here is opened +/// `SQLITE_OPEN_READONLY` rather than merely used carefully — a future edit +/// that tried to insert or delete would fail at the connection, not silently +/// become a second owner of the file. +/// +/// ## The format it decodes +/// +/// One `fix` table of `(id, t, lat, lng)`, where every value is a **delta from +/// the previous row** except on anchors, and an anchor is any row whose `id` +/// is a multiple of [_anchorEvery]. Latitude and longitude are ten-thousandths +/// of a degree; `t` is Unix seconds. +/// +/// Storing differences is what makes the file small, and it costs nothing to +/// do: SQLite already writes a small integer in one byte and a large one in +/// four, so a step of a few hundred metres is a byte where an absolute +/// coordinate is four. There is no private blob, and decoding is addition. +/// +/// The price is that a row cannot be read on its own — the walk has to start +/// at an anchor. That is why [since] resolves a starting rowid first instead +/// of asking for `WHERE t >= ?`, which would match rows whose `t` is an +/// interval rather than an instant. +class LocationTrack { + LocationTrack._(this._db, this._path); + + static const _file = 'location_track.db'; + + /// Rows between absolute anchors. **Must match `anchorEvery` in both native + /// stores.** Changing it on one side would not throw — it would return + /// positions displaced by however far the device moved since the last real + /// anchor, which is a wrong answer that looks like a right one. + static const _anchorEvery = 64; + + /// Degrees per stored unit. + static const _scale = 10000.0; + + final SqliteDatabase _db; + final String _path; + + /// Opens the track, or null when the native side has never written one. + /// + /// Absence is the ordinary state on a fresh install, and on any device that + /// never granted background location, so it is not an error and is not + /// logged as one. + static Future open() async { + try { + // Application Support on iOS, `filesDir` on Android — the one directory + // both native writers were pointed at, because a reader that had to + // guess would not fail, it would quietly report an empty history. + final directory = await getApplicationSupportDirectory(); + return at('${directory.path}/$_file'); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'opening location track'); + return null; + } + } + + /// Opens a track at an explicit path, or null if there is no file there. + /// + /// [open] is this plus the directory lookup. Split out so a test can point + /// at a fixture and still go through the real read-only connection — the + /// part most likely to break, and the part whose breakage looks exactly + /// like an empty history. + static LocationTrack? at(String path) { + if (!File(path).existsSync()) return null; + return LocationTrack._( + SqliteDatabase.withFactory(_ReadOnlyFactory(path: path)), + path, + ); + } + + Future close() => _db.close(); + + /// The underlying connection, so a test can prove it rejects writes. + /// + /// Exposed rather than asserted in a comment: "Dart never writes here" is + /// the load-bearing half of the design, and the only way to show it holds + /// is to try a write and watch SQLite refuse. + @visibleForTesting + SqliteDatabase get connection => _db; + + /// Every fix recorded at or after [from], oldest first. + /// + /// [limit] keeps the most recent that many and drops the rest, which is the + /// only bound worth having here: the budget allows a few million rows, and + /// materialising all of them as objects would cost far more memory than the + /// file costs disk. + Future> since(DateTime from, {int? limit}) async { + try { + final start = await _anchorAtOrBefore(from); + final fixes = await _decodeFrom(start); + final wanted = fixes.where((fix) => !fix.time.isBefore(from)).toList(); + if (limit == null || wanted.length <= limit) return wanted; + return wanted.sublist(wanted.length - limit); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'reading location track'); + return const []; + } + } + + /// How many fixes are stored and how large the file is. + /// + /// For the storage screen. This file is the one part of the app's disk use + /// that grows without anybody opening anything, so it is worth being able + /// to see it. + Future<({int fixes, int bytes})> stats() async { + try { + final row = await _db.get('SELECT COUNT(*) AS n FROM fix'); + final file = File(_path); + return ( + fixes: ((row['n'] as num?) ?? 0).toInt(), + bytes: file.existsSync() ? file.lengthSync() : 0, + ); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'location track stats'); + return (fixes: 0, bytes: 0); + } + } + + /// The rowid of the newest anchor no later than [time], or 0 for "start at + /// the beginning". + /// + /// Only anchors are consulted because only anchors carry an absolute `t`; a + /// delta row's `t` is an interval, and comparing it to a wall clock would + /// match essentially at random. + /// + /// Descending, so the scan stops at the first hit. A window of the last day + /// or week — what anything asking this actually wants — reads a handful of + /// rows off the end of the rowid index and stops. Asking for a window older + /// than the whole track is the one case that scans it all, and it correctly + /// answers 0. + Future _anchorAtOrBefore(DateTime time) async { + final rows = await _db.getAll( + 'SELECT id FROM fix WHERE id % $_anchorEvery = 0 AND t <= ? ' + 'ORDER BY id DESC LIMIT 1', + [time.millisecondsSinceEpoch ~/ 1000], + ); + return rows.isEmpty ? 0 : ((rows.first['id'] as num?) ?? 0).toInt(); + } + + /// Rebuilds absolute positions from [startId] forward. + /// + /// [startId] has to be an anchor or 0 — the first row of the table is always + /// an anchor, because the writer rounds up to a boundary whenever it has + /// nothing to count from and eviction only ever deletes whole groups. The + /// `t == null` arm below is the belt to that braces: a first row that + /// somehow is not an anchor is read as absolute, which is the only reading + /// that can be right. + Future> _decodeFrom(int startId) async { + final rows = await _db.getAll( + 'SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id', + [startId], + ); + final fixes = []; + var t = 0; + var lat = 0; + var lng = 0; + var started = false; + for (final row in rows) { + final id = (row['id'] as num).toInt(); + if (!started || id % _anchorEvery == 0) { + t = (row['t'] as num).toInt(); + lat = (row['lat'] as num).toInt(); + lng = (row['lng'] as num).toInt(); + started = true; + } else { + t += (row['t'] as num).toInt(); + lat += (row['lat'] as num).toInt(); + lng += (row['lng'] as num).toInt(); + } + fixes.add( + TrackFix( + time: DateTime.fromMillisecondsSinceEpoch(t * 1000, isUtc: true), + latitude: lat / _scale, + longitude: lng / _scale, + ), + ); + } + return fixes; + } +} + +/// Opens every connection `SQLITE_OPEN_READONLY`, and runs no pragma that +/// would persist anything. +/// +/// sqlite_async always opens one writable "primary" connection; this replaces +/// the options it passes so that connection is read-only too. The journal-mode +/// and journal-size pragmas are suppressed the same way — the file is already +/// in WAL because the native writer put it there, and re-asserting it from +/// here would be this side taking a write lock on a file it does not own. +/// +/// One reader is plenty: nothing reads this concurrently, and each connection +/// is a file handle held for the life of the app. +base class _ReadOnlyFactory extends NativeSqliteOpenFactory { + _ReadOnlyFactory({required super.path}) + : super( + sqliteOptions: const SqliteOptions( + journalMode: null, + journalSizeLimit: null, + synchronous: null, + maxReaders: 1, + ), + ); + + static const _readOnly = SqliteOpenOptions( + primaryConnection: false, + readOnly: true, + ); + + @override + Database openNativeConnection(SqliteOpenOptions options) => + super.openNativeConnection(_readOnly); +} diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 7a5e624b5..123cd2d5f 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -13,10 +13,30 @@ import 'package:talker_flutter/talker_flutter.dart'; /// backed by Talker, which keeps a history for the in-app log screen and /// captures uncaught Flutter/async errors. abstract final class Log { - /// Monotonic stopwatch started when the app boots — lets any code report - /// "how long after launch" (e.g. bootstrap-ready and first-frame markers). + /// Monotonic stopwatch for "how long after launch" markers — bootstrap-ready + /// and first-frame among them. + /// + /// Started by [startClock], not by this initialiser, and the difference is + /// not academic. A `static final` in Dart initialises **lazily, on first + /// read**. While `..start()` here was the only thing that started it, the + /// first read was the bootstrap-ready log line itself — so that line reported + /// `0 ms` on every launch, and the first-frame marker measured from + /// bootstrap-ready rather than from start. Two numbers that looked like + /// measurements and were not. static final Stopwatch sinceStart = Stopwatch()..start(); + /// Starts the launch clock. Must be the first statement of `bootstrap`. + /// + /// Reading [sinceStart] here is what forces its lazy initialiser to run; the + /// reset then pins zero to this moment rather than to whoever happened to + /// look first. + static void startClock() => sinceStart + ..reset() + ..start(); + + /// Milliseconds since [startClock], for a phase marker. + static int get sinceStartMs => sinceStart.elapsedMilliseconds; + static final TalkerSettings _settings = TalkerSettings( useConsoleLogs: kDebugMode, // The tag on every line, in the log screen and in the console alike. diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart index 1842d5a70..d46f47dfa 100644 --- a/lib/core/network/api_exception.dart +++ b/lib/core/network/api_exception.dart @@ -38,6 +38,9 @@ Failure mapException(Object error) { return const TimeoutFailure('Request timed out'); case DioExceptionType.badResponse: final code = error.response?.statusCode; + // 404 separately: it is the one status where retrying is not a + // recovery, and the caller needs to know that to offer something else. + if (code == 404) return const NotFoundFailure('Not found'); return NetworkFailure('Server error${code == null ? '' : ' ($code)'}'); case DioExceptionType.cancel: return const NetworkFailure('Request cancelled'); diff --git a/lib/core/notifications/notification_channels.dart b/lib/core/notifications/notification_channels.dart index 79e73d2cc..5ed6b3ad2 100644 --- a/lib/core/notifications/notification_channels.dart +++ b/lib/core/notifications/notification_channels.dart @@ -435,16 +435,6 @@ abstract final class NotificationChannels { ? NotificationBehaviour.alerts : NotificationBehaviour.sounds; } - - /// The group a [channelKey] belongs to (`group_eew`, `group_eq`, …), or null - /// if the key isn't a known channel. Lets the tap router resolve a screen by - /// group so a new channel routes correctly without touching the mapping. - static String? groupOf(String channelKey) { - for (final channel in channels) { - if (channel.channelKey == channelKey) return channel.channelGroupKey; - } - return null; - } } /// The coarse outcome of a channel firing — see diff --git a/lib/core/notifications/notification_tap.dart b/lib/core/notifications/notification_tap.dart index 5429cd4e8..677bb4438 100644 --- a/lib/core/notifications/notification_tap.dart +++ b/lib/core/notifications/notification_tap.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart'; /// A tapped notification, normalised from either ingress (FCM `message.data` or @@ -12,13 +14,76 @@ class NotificationTap { const NotificationTap({this.channelKey, this.data = const {}}); /// Builds a tap from a raw data/payload map (FCM's dynamic values or awesome's - /// nullable strings), dropping nulls and reading the channel from `channel`. - factory NotificationTap.fromData(Map? raw) { + /// nullable strings), dropping nulls. + /// + /// [channelKey] is the channel the notification was actually posted to, and + /// it wins over the payload's `channel` key. That order is the whole reason + /// taps used to land on Home: only notifications **this app** displayed carry + /// `channel` in their payload — the service puts it there. A server push is + /// rendered from awesome's own wire format, where the channel is a model + /// field and the payload holds one key, `content`, with the producer's JSON. + /// + /// Three layers are merged into [data], each overwriting the last: + /// + /// 1. the fields of `content`, + /// 2. the flat payload keys, + /// 3. the producer's own map — **which wins**, and that is the point of it. + /// `content` is awesome's model object, so a field dropped in beside `id` + /// or `body` competes for a name the library already owns. + /// + /// That map is read from `payload` first and `extra` second. `payload` is the + /// slot awesome's model actually reserves for application data, and on iOS it + /// is the **only** one that survives: the native side parses `content` into + /// its model and drops every key it does not recognise, so a custom `extra` + /// arrives as an empty payload. Android took a different route into the same + /// place — its FCM data map is copied wholesale — and happened to carry + /// `extra` through, which is exactly why this looked fine on one platform and + /// silently lost the deep-link target on the other. `extra` is still read, for + /// any producer that has not moved yet. + /// + /// `content`, `payload` and `extra` are containers, not values, so none of + /// them appears in [data] under its own name. + factory NotificationTap.fromData( + Map? raw, { + String? channelKey, + }) { + const containers = {'content', 'payload', 'extra'}; final data = {}; - raw?.forEach((key, value) { - if (value != null) data[key] = value.toString(); - }); - return NotificationTap(channelKey: data['channel'], data: data); + void put(Map source) { + source.forEach((key, value) { + if (value != null && !containers.contains(key)) { + data[key] = value.toString(); + } + }); + } + + final content = _decode(raw?['content']); + put(content); + if (raw != null) put(raw); + put(_decode(content['extra'] ?? raw?['extra'])); + put(_decode(content['payload'] ?? raw?['payload'])); + + return NotificationTap( + channelKey: channelKey ?? data['channel'] ?? data['channelKey'], + data: data, + ); + } + + /// Reads a nested object that may arrive already decoded or as a JSON string. + /// + /// Both shapes are real: the producer nests `extra` as an object, while + /// `content` reaches the app as the raw string awesome passed through. Parsed + /// leniently — malformed or non-object input is treated as absent, never + /// thrown on, because a tap must still route. + static Map _decode(Object? raw) { + if (raw is Map) return Map.from(raw); + if (raw is! String || raw.isEmpty) return const {}; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? Map.from(decoded) : const {}; + } on FormatException { + return const {}; + } } /// The alert channel that fired (drives which screen the tap opens). diff --git a/lib/core/notifications/notification_taps.dart b/lib/core/notifications/notification_taps.dart index 40ebf8103..67ff799b9 100644 --- a/lib/core/notifications/notification_taps.dart +++ b/lib/core/notifications/notification_taps.dart @@ -27,8 +27,12 @@ abstract final class NotificationTaps { static void route(NotificationTap tap) { final handler = onTap; if (handler != null) { + Log.info('Notification tap: routing now, channel=${tap.channelKey}'); handler(tap); } else { + Log.info( + 'Notification tap: router not ready, stashing channel=${tap.channelKey}', + ); _pending = tap; } } @@ -36,8 +40,21 @@ abstract final class NotificationTaps { /// awesome_notifications tap entry point (must be a top-level static method). @pragma('vm:entry-point') static Future onActionReceived(ReceivedAction action) async { - Log.debug('Notification tapped: channelKey=${action.channelKey}'); - route(NotificationTap.fromData(action.payload)); + // The channel comes off the action, not the payload — see + // [NotificationTap.fromData] for why the payload is empty on a push. + final tap = NotificationTap.fromData( + action.payload, + channelKey: action.channelKey, + ); + // The resolved map, not just its keys. `payload=[]` was the whole symptom + // of the iOS deep link never firing, and a list of key names could not show + // that the values behind them were the ones routing depends on. + Log.info( + 'Notification tapped: channel=${tap.channelKey} id=${tap.id} ' + 'data=${tap.data} lifeCycle=${action.actionLifeCycle?.name}', + ); + Log.debug('Notification raw payload: ${action.payload}'); + route(tap); } /// Replays a tap that arrived before [onTap] was registered (cold start). @@ -45,6 +62,7 @@ abstract final class NotificationTaps { final tap = _pending; if (tap == null) return; _pending = null; + Log.info('Notification tap: replaying stashed channel=${tap.channelKey}'); onTap?.call(tap); } } diff --git a/lib/core/platform/background_location.dart b/lib/core/platform/background_location.dart index 94f3d148e..0827c7947 100644 --- a/lib/core/platform/background_location.dart +++ b/lib/core/platform/background_location.dart @@ -130,6 +130,24 @@ class BackgroundLocationService { } } + /// Deletes the on-device movement history. + /// + /// Native, not a file delete from here. The recorder caches its database + /// handle for the life of the process, so a file removed from Dart would be + /// an unlinked inode the native side keeps writing into — the rows come back + /// on the next read and the space is never returned. The side that owns + /// every write owns the delete too. + Future clearTrack() async { + try { + await _channel.invokeMethod('clearTrack'); + } on MissingPluginException { + // Unsupported platform / test harness — nothing recorded, nothing to + // clear. + } on Object catch (error, stackTrace) { + Log.handle(error, stackTrace, 'background location clearTrack'); + } + } + Future stop() async { try { await _channel.invokeMethod('stop'); diff --git a/lib/core/settings/home_area.dart b/lib/core/settings/home_area.dart index eea707c64..ea26ec5ae 100644 --- a/lib/core/settings/home_area.dart +++ b/lib/core/settings/home_area.dart @@ -6,11 +6,22 @@ /// the saved [SavedArea]s. sealed class HomeArea { const HomeArea(); + + /// The township code this area stands for — null for 全國, and null for + /// 所在地 until GPS reports one. + /// + /// Declared here rather than switched at each reader: a screen that needs a + /// code needs *this* derivation, and nine hand-written copies of it is nine + /// chances for a future area kind to be quietly filed under 全國. + String? get code; } /// 全國 — the whole-country view. class NationwideArea extends HomeArea { const NationwideArea(); + + @override + String? get code => null; } /// 所在地 — the current GPS township. [code] is null when GPS is unavailable, so @@ -18,6 +29,7 @@ class NationwideArea extends HomeArea { class CurrentArea extends HomeArea { const CurrentArea(this.code); + @override final String? code; } @@ -25,5 +37,6 @@ class CurrentArea extends HomeArea { class SavedArea extends HomeArea { const SavedArea(this.code); + @override final String code; } diff --git a/lib/core/settings/region_store.dart b/lib/core/settings/region_store.dart index 365cb377a..f2445755b 100644 --- a/lib/core/settings/region_store.dart +++ b/lib/core/settings/region_store.dart @@ -51,6 +51,10 @@ class RegionStore extends ChangeNotifier { /// The selected area. HomeArea get selected => areas[selectedIndex]; + /// The selected area's township code — null for 全國, and for 所在地 without + /// a GPS fix. See [HomeArea.code]. + String? get selectedCode => selected.code; + /// Selects the area at [index]; ignored if unchanged / out of range. void select(int index) { final clamped = index.clamp(0, count - 1); @@ -128,6 +132,8 @@ class RegionStore extends ChangeNotifier { final target = newIndex.clamp(0, _saved.length - 1); if (target == oldIndex) return; + // Deliberately not [selectedCode]: only a *saved* area's slot moves, so + // 所在地 must read as null here even though it has a code. final selectedCode = switch (selected) { SavedArea(:final code) => code, _ => null, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 3487108d9..f46bfe50f 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -30,14 +30,20 @@ /// | `dpip.db` | `mesh_nodes` | meshtastic | /// | `http_etag_cache.db` | `http_cache` | cache | /// | `http_etag_cache.db` | `net_bucket` | cache | +/// | `location_track.db` | `fix` | movement | +/// +/// The third file is not opened here and has no handle on [AppDatabase], +/// because nothing in Dart writes it: background location arrives when Dart is +/// not running, so the native stores own both the writes and the eviction that +/// keeps it under 50 MB. Dart reads it through +/// `lib/core/geo/location_track.dart`, on a connection opened read-only. It is +/// listed here because this table is where someone looks to find out what the +/// app keeps on disk, and a file that grows on its own belongs on that list. library; import 'package:dpip/core/logging/log.dart'; import 'package:sqlite_async/sqlite_async.dart'; -/// Schema version of the durable database. -const int appDatabaseVersion = 1; - /// The tables the cache file owns — the complete list of what [clearCache] /// may destroy. Anything not named here is, by construction, out of reach. const List cacheTables = ['http_cache', 'net_bucket']; diff --git a/lib/core/storage/app_storage_scan.dart b/lib/core/storage/app_storage_scan.dart index 76f269341..62c89dd58 100644 --- a/lib/core/storage/app_storage_scan.dart +++ b/lib/core/storage/app_storage_scan.dart @@ -112,6 +112,10 @@ List storageBreakdown(StorageScan scan) { } known('ETag cache (SQLite)', (f) => f.name.startsWith('http_etag_cache.db')); + // Native-written movement history, budgeted at 50 MB. It grows without + // anybody opening anything, so it is the one slice a user could otherwise + // find no explanation for. `startsWith` catches the -wal and -shm companions. + known('Location track', (f) => f.name.startsWith('location_track.db')); known( 'MapLibre', (f) => f.path.contains('MapLibre') || f.path.contains('mapbox'), diff --git a/lib/features/bug_tracker/data/bug_repository_impl.dart b/lib/features/bug_tracker/data/bug_repository_impl.dart index cfb821be6..8a6a1c96b 100644 --- a/lib/features/bug_tracker/data/bug_repository_impl.dart +++ b/lib/features/bug_tracker/data/bug_repository_impl.dart @@ -11,7 +11,10 @@ import 'package:flutter/foundation.dart'; /// The forum tag that marks a thread as about THIS app. A routing marker, not /// a category — it is filtered on and never rendered. -const String appBugTag = 'DPIP'; +/// +/// In canonical form: see [canonicalBugTag] for why the two endpoints disagree +/// about how they spell it. +const String appBugTag = 'dpip'; class BugRepositoryImpl implements BugRepository { const BugRepositoryImpl(this._api); @@ -41,6 +44,32 @@ String _normalise(String body) => body.replaceAllMapped( (match) => match.group(1) ?? match.input, ); +/// The two tracker endpoints spell the same tag two different ways. +/// +/// The index sends the forum's slugs — `["dpip", "bug"]`. The detail endpoint +/// still reflects Discord's raw forum labels, which are bilingual — `["臭蟲 +/// bug", "DPIP", "已解決 fixed"]`. Left alone, one thread carries `bug` in the +/// list and `臭蟲` on its own page, and the routing marker matches `dpip` in +/// one place and `DPIP` in the other — which drops every thread from the +/// index, because nothing equals `DPIP` once the server started sending slugs. +/// +/// The slug is the canonical form, so a bilingual label yields its English +/// tail. `臭蟲 bug` → `bug`, `DPIP` → `dpip`, `bug` → `bug`. +String canonicalBugTag(String tag) { + final trimmed = tag.trim(); + final space = trimmed.lastIndexOf(' '); + return (space < 0 ? trimmed : trimmed.substring(space + 1)).toLowerCase(); +} + +List _canonicalTags(Object? raw) { + if (raw is! List) return const []; + return [ + for (final tag in raw) + if (tag is String) + if (canonicalBugTag(tag) case final slug when slug != appBugTag) slug, + ]; +} + Map _asObject(Object? value, String what) { if (value is Map) return Map.from(value); throw FormatException('bug tracker: $what expected an object'); @@ -114,23 +143,21 @@ List parseBugThreads(Object? body) { for (final entry in raw) _normaliseIntoThread(BugThread.fromJson(_asObject(entry, 'thread'))), ]; - // `DPIP` is the forum's routing marker: threads without it are not about - // this app (other bots share the channel), so they never reach the index. - // Locked threads are staff-side conversations — same. + // `dpip` is the forum's routing marker: threads without it are not about + // this app (other products share the tracker), so they never reach the + // index. Locked threads are staff-side conversations — same. Matched in + // canonical form, because the marker arrives spelt both ways. threads.removeWhere( - (thread) => thread.locked || !thread.tags.contains(appBugTag), + (thread) => + thread.locked || !thread.tags.map(canonicalBugTag).contains(appBugTag), ); - // The routing marker is a filter, never a category label; bilingual labels - // keep their Chinese head only. freezed lists are unmodifiable, so this + // The routing marker is a filter, never a category label, so it is dropped + // along with the canonicalisation. freezed lists are unmodifiable, so this // rebuilds each thread instead of mutating it. threads = [ for (final thread in threads) thread.copyWith( - tags: [ - for (final tag in thread.tags) - if (tag != appBugTag) - tag.contains(' ') ? tag.split(' ').first : tag, - ], + tags: _canonicalTags(thread.tags), authorName: _authorOf(users, thread.author).name, authorAvatar: _authorOf(users, thread.author).avatar, ), @@ -147,11 +174,7 @@ BugThreadDetail parseBugThreadDetail(Object? body) { final users = _parseUsers(map['users']); final opAuthor = _authorOf(users, map['author']); final thread = BugThread.fromJson(map).copyWith( - tags: [ - for (final tag in (map['tags'] ?? const []) as List) - if (tag != appBugTag) - (tag as String).contains(' ') ? tag.split(' ').first : tag, - ], + tags: _canonicalTags(map['tags']), authorName: opAuthor.name, authorAvatar: opAuthor.avatar, ); diff --git a/lib/features/bug_tracker/domain/bug_thread.dart b/lib/features/bug_tracker/domain/bug_thread.dart index 5aff7cfc9..6f6877ab8 100644 --- a/lib/features/bug_tracker/domain/bug_thread.dart +++ b/lib/features/bug_tracker/domain/bug_thread.dart @@ -33,12 +33,30 @@ class UnixSecondsDateTime implements JsonConverter { int toJson(DateTime value) => value.millisecondsSinceEpoch ~/ 1000; } -/// The tracker staff who answer reports — rendered with a developer badge so -/// official replies are visually distinct from user chatter. -const Set bugTrackerAdminIds = {780043079385612319}; - -/// Whether this author id belongs to tracker staff. -bool isBugTrackerStaff(int authorId) => bugTrackerAdminIds.contains(authorId); +/// The developers who build the app — rendered with a「開發人員」badge and a +/// primary-coloured name. +const Set bugTrackerAdminIds = {780043079385612319, 592012263834255360}; + +/// The tracker team who triage and reply — rendered with a「工作人員」badge, +/// one step quieter than the developer badge but still distinct from users. +const Set bugTrackerStaffIds = { + 452103762320949248, + 815574915901554699, + 878792688416227368, + 860479942550093866, + 898836485397180426, + 905433558921920562, + 1001016404289536051, +}; + +/// The role an author id carries on the tracker, driving badge and colour. +enum BugAuthorRole { user, staff, admin } + +BugAuthorRole bugAuthorRole(int authorId) { + if (bugTrackerAdminIds.contains(authorId)) return BugAuthorRole.admin; + if (bugTrackerStaffIds.contains(authorId)) return BugAuthorRole.staff; + return BugAuthorRole.user; +} /// One staff/victim reply inside a reported-bug thread. @freezed diff --git a/lib/features/bug_tracker/presentation/pages/bug_list_page.dart b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart index 9b5c99b99..3f53788a6 100644 --- a/lib/features/bug_tracker/presentation/pages/bug_list_page.dart +++ b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart @@ -124,6 +124,10 @@ class _BugListPageState extends State { future: repo.threads, refreshSignal: _refresh, isEmpty: (threads) => threads.isEmpty, + empty: (context) => EmptyView( + icon: Icons.bug_report_outlined, + message: l10n.bugTrackerEmpty, + ), builder: (context, threads) { final theme = Theme.of(context); final visible = _visibleThreads(threads); @@ -323,6 +327,27 @@ String _bugPreview(String body) => body .trim(); /// One thread row — title, tag badges, body preview, author and reply count. +/// The dot between two facts in a card's meta row. +/// +/// Its own widget so the padding either side stays symmetric wherever it is +/// used: a bare `Text('·')` inherits whatever `SizedBox` happens to sit next to +/// it, and the separator then sits visibly closer to one side than the other. +class _MetaDot extends StatelessWidget { + const _MetaDot({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2), + // l10n-ignore: punctuation, not display text + child: Text( + '·', + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color), + ), + ); +} + class _ThreadCard extends StatelessWidget { const _ThreadCard({required this.thread, required this.avatarFor}); @@ -418,13 +443,27 @@ class _ThreadCard extends StatelessWidget { color: colors.onSurfaceVariant, ), ), - const SizedBox(width: AppSpacing.sm), + // The reply count and the date are two different facts about + // the thread, and spacing alone left them reading as one + // run-on figure. A middle dot is the separator, not a word — + // it needs no translation and carries none. + // l10n-ignore: punctuation, not display text + _MetaDot(color: colors.onSurfaceVariant), Text( date, style: theme.textTheme.labelSmall?.copyWith( color: colors.onSurfaceVariant, ), ), + // The row ends where the tap leads. The whole card is an + // InkWell, but nothing in it said so until now — every other + // list in the app puts a chevron at the end of a row that + // opens something. + Icon( + Icons.chevron_right, + size: 16, + color: colors.onSurfaceVariant, + ), ], ), ], diff --git a/lib/features/bug_tracker/presentation/pages/bug_thread_page.dart b/lib/features/bug_tracker/presentation/pages/bug_thread_page.dart index add2c4ae0..d2b2a6110 100644 --- a/lib/features/bug_tracker/presentation/pages/bug_thread_page.dart +++ b/lib/features/bug_tracker/presentation/pages/bug_thread_page.dart @@ -158,7 +158,7 @@ class _OpeningPost extends StatelessWidget { final created = DateFormat('yyyy/MM/dd HH:mm') .format(thread.createdAt.toLocal()); // OP author id lives on the model; see bug_thread.dart. - final staff = isBugTrackerStaff(thread.author); + final role = bugAuthorRole(thread.author); return Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( @@ -225,14 +225,20 @@ class _OpeningPost extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w600, - color: staff ? colors.primary : null, + color: role == BugAuthorRole.admin + ? colors.primary + : null, ), ), ), - if (staff) ...[ + if (role == BugAuthorRole.admin) ...[ const SizedBox(width: AppSpacing.xs), const _DeveloperBadge(), ], + if (role == BugAuthorRole.staff) ...[ + const SizedBox(width: AppSpacing.xs), + const _StaffBadge(), + ], ], ), Text( @@ -270,7 +276,7 @@ class _ChatReply extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final staff = isBugTrackerStaff(message.author); + final role = bugAuthorRole(message.author); final time = DateFormat('yyyy/MM/dd HH:mm').format(message.time.toLocal()); return Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -301,14 +307,20 @@ class _ChatReply extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, - color: staff ? colors.primary : null, + color: role == BugAuthorRole.admin + ? colors.primary + : null, ), ), ), - if (staff) ...[ + if (role == BugAuthorRole.admin) ...[ const SizedBox(width: AppSpacing.xs), const _DeveloperBadge(), ], + if (role == BugAuthorRole.staff) ...[ + const SizedBox(width: AppSpacing.xs), + const _StaffBadge(), + ], const SizedBox(width: AppSpacing.xs), Text( time, @@ -378,6 +390,32 @@ class _CannotDisplay extends StatelessWidget { } } +/// The small「工作人員」tag beside triage-team names — tertiary tint, one +/// step quieter than the developer badge. +class _StaffBadge extends StatelessWidget { + const _StaffBadge(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colors.tertiary.withValues(alpha: 0.15), + borderRadius: AppRadius.small, + ), + child: Text( + AppLocalizations.of(context).bugTrackerStaff, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.tertiary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + /// The small「開發人員」tag beside staff names. class _DeveloperBadge extends StatelessWidget { const _DeveloperBadge(); diff --git a/lib/features/bug_tracker/presentation/widgets/bug_tag_badge.dart b/lib/features/bug_tracker/presentation/widgets/bug_tag_badge.dart index 153efb997..7ffd6981d 100644 --- a/lib/features/bug_tracker/presentation/widgets/bug_tag_badge.dart +++ b/lib/features/bug_tracker/presentation/widgets/bug_tag_badge.dart @@ -14,71 +14,95 @@ import 'package:dpip/app/theme/app_radius.dart'; import 'package:flutter/material.dart'; /// Which known tag this is, driving the badge's icon and accent colour. +/// +/// The vocabulary is the tracker forum's, narrowed to what a DPIP reader can +/// actually see. The forum declares nineteen tags; seven of them — +/// `station`, `trem_lite`, `trem_variety`, `rts_map`, `es_net_wave`, `web`, +/// `plugin` — belong to ExpTech's other products, and the index endpoint is +/// already filtered to `dpip`, so they cannot arrive here. Measured over the +/// 200 most recent threads: `bug` 111, `fixed` 77, `confirmed` 44, `invalid` +/// 30, `in_triage` 23, `api` 22, `duplicate` 17, `improvement` 11, +/// `processing` 3, `wontfix` 2 — and `dpip` on all 200, which is why the data +/// layer drops it rather than rendering a badge that says nothing. +/// +/// `vulnerability` has not appeared in that window but is kept: it is declared, +/// it is a security state, and falling through to neutral grey is the wrong +/// way to find that out. enum BugTagKind { bug, duplicate, confirmed, improvement, api, - needsInfo, - inProgress, + inTriage, + processing, fixed, wontfix, invalid, vulnerability, other; - /// Tags arrive as the forum's bilingual labels; after cleaning they carry - /// the Chinese head only (`臭蟲`, `處理中`…), so exact Chinese matches lead - /// and English keywords stay as a tolerant fallback. + /// Tags reach the UI canonicalised to the forum's slugs by the data layer, + /// so an exact match leads. The bilingual labels stay as a fallback — the + /// detail endpoint still reflects Discord's raw `臭蟲 bug` form, and a body + /// replayed from the offline store predates the canonicalisation. static BugTagKind of(String tag) { - final t = tag.trim().toLowerCase(); - // l10n-ignore: server tag value - if (t.contains('duplicate') || tag == '重複') { - return duplicate; - } + final trimmed = tag.trim(); + final space = trimmed.lastIndexOf(' '); + final slug = (space < 0 ? trimmed : trimmed.substring(space + 1)) + .toLowerCase(); + return switch (slug) { + // l10n-ignore: server tag value + 'bug' => BugTagKind.bug, + // l10n-ignore: server tag value + 'duplicate' => BugTagKind.duplicate, + // l10n-ignore: server tag value + 'confirmed' => BugTagKind.confirmed, + // l10n-ignore: server tag value + 'improvement' => BugTagKind.improvement, + // l10n-ignore: server tag value + 'api' => BugTagKind.api, + // l10n-ignore: server tag value + 'in_triage' => BugTagKind.inTriage, + // l10n-ignore: server tag value + 'processing' => BugTagKind.processing, + // l10n-ignore: server tag value + 'fixed' => BugTagKind.fixed, + // l10n-ignore: server tag value + 'wontfix' => BugTagKind.wontfix, + // l10n-ignore: server tag value + 'invalid' => BugTagKind.invalid, + // l10n-ignore: server tag value + 'vulnerability' => BugTagKind.vulnerability, + _ => _fromLabel(trimmed), + }; + } + + /// The Chinese head of a bilingual label, for payloads that never went + /// through canonicalisation. + static BugTagKind _fromLabel(String tag) => switch (tag) { // l10n-ignore: server tag value - if (t.contains('confirmed') || tag == '已確認') { - return confirmed; - } + '臭蟲' => BugTagKind.bug, // l10n-ignore: server tag value - if (t.contains('fixed') || tag == '已解決') { - return fixed; - } + '重複' => BugTagKind.duplicate, // l10n-ignore: server tag value - if (t.contains('wontfix') || tag == '無法解決') { - return wontfix; - } + '已確認' => BugTagKind.confirmed, // l10n-ignore: server tag value - if (t.contains('invalid') || tag == '無效') { - return invalid; - } + '增強' => BugTagKind.improvement, // l10n-ignore: server tag value - if (t.contains('improvement') || tag == '增強') { - return improvement; - } + '需要更多資訊' => BugTagKind.inTriage, // l10n-ignore: server tag value - if (t.contains('triage') || tag == '需要更多資訊') { - return needsInfo; - } + '處理中' => BugTagKind.processing, // l10n-ignore: server tag value - if (t.contains('progress') || tag == '處理中') { - return inProgress; - } + '已解決' => BugTagKind.fixed, // l10n-ignore: server tag value - if (t.contains('vulnerab') || tag == '漏洞') { - return vulnerability; - } + '無法解決' => BugTagKind.wontfix, // l10n-ignore: server tag value - if (t == 'api') { - return api; - } + '無效' => BugTagKind.invalid, // l10n-ignore: server tag value - if (t.contains('bug') || tag == '臭蟲') { - return bug; - } - return other; - } + '漏洞' => BugTagKind.vulnerability, + _ => BugTagKind.other, + }; } /// The accent colour each kind renders with — one hue per state, close to @@ -90,14 +114,55 @@ Color bugTagAccent(BugTagKind kind) => switch (kind) { BugTagKind.fixed => const Color(0xFF1A7F37), BugTagKind.improvement => const Color(0xFF8250DF), BugTagKind.api => const Color(0xFF6639BA), - BugTagKind.inProgress => const Color(0xFFBF8700), - BugTagKind.needsInfo => const Color(0xFF9A6700), + BugTagKind.processing => const Color(0xFFBF8700), + BugTagKind.inTriage => const Color(0xFF9A6700), BugTagKind.duplicate => const Color(0xFF6E7781), BugTagKind.wontfix => const Color(0xFF57606A), BugTagKind.invalid => const Color(0xFF0550AE), BugTagKind.other => const Color(0xFF6E7781), }; +/// The tag's display name, in formal Taiwanese Traditional Chinese. +/// +/// Hardcoded rather than routed through `AppLocalizations`, by decision: this +/// is a fixed eleven-word vocabulary owned by the tracker, the tracker itself +/// is Chinese-only, and every thread it lists has a Chinese title and body. A +/// reader who cannot read the threads gains nothing from a translated badge. +/// The same call was made for the notification test payloads. +/// +/// The forum's own Chinese labels are looser than these — `臭蟲` for a bug, +/// `無法解決` for wontfix (which says "cannot", where the tag means "will +/// not"), `已解決` for fixed (resolved, not repaired). These are the formal +/// readings of what the slug actually means. +/// +/// An unknown tag falls through to its raw slug: better a reader sees +/// `es_net_wave` than a badge that has silently invented a name for it. +String bugTagLabel(BugTagKind kind, String raw) => switch (kind) { + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.bug => '錯誤', + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.inTriage => '待分類', + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.confirmed => '已確認', + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.processing => '處理中', + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.fixed => '已修復', + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.wontfix => '不予修復', + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.invalid => '無效', + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.duplicate => '重複回報', + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.improvement => '功能改進', + // l10n-ignore: tracker vocabulary, Chinese-only by design + BugTagKind.vulnerability => '安全漏洞', + // l10n-ignore: an acronym, and read as one in Taiwan + BugTagKind.api => 'API', + BugTagKind.other => raw, +}; + IconData? _iconFor(BugTagKind kind) => switch (kind) { BugTagKind.bug => Icons.bug_report_outlined, BugTagKind.vulnerability => Icons.gpp_maybe_outlined, @@ -105,8 +170,8 @@ IconData? _iconFor(BugTagKind kind) => switch (kind) { BugTagKind.fixed => Icons.check_circle_outlined, BugTagKind.improvement => Icons.auto_awesome, BugTagKind.api => Icons.api, - BugTagKind.inProgress => Icons.autorenew, - BugTagKind.needsInfo => Icons.help_outline, + BugTagKind.processing => Icons.autorenew, + BugTagKind.inTriage => Icons.help_outline, BugTagKind.duplicate => Icons.content_copy, BugTagKind.wontfix => Icons.block, BugTagKind.invalid => Icons.cancel_outlined, @@ -144,7 +209,7 @@ class BugTagBadge extends StatelessWidget { const SizedBox(width: 4), ], Text( - tag, + bugTagLabel(kind, tag), style: theme.textTheme.labelSmall?.copyWith( color: accent, fontWeight: FontWeight.w700, @@ -212,7 +277,7 @@ class BugTagFilterChip extends StatelessWidget { const SizedBox(width: 4), ], Text( - tag, + bugTagLabel(kind, tag), style: theme.textTheme.labelMedium?.copyWith( color: selected ? accent : colors.onSurfaceVariant, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, diff --git a/lib/features/data/presentation/observer_place.dart b/lib/features/data/presentation/observer_place.dart index 35c2fd9fb..b0d19f69d 100644 --- a/lib/features/data/presentation/observer_place.dart +++ b/lib/features/data/presentation/observer_place.dart @@ -13,7 +13,6 @@ library; import 'package:dpip/core/geo/town.dart'; import 'package:dpip/core/geo/town_directory.dart'; -import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; @@ -26,11 +25,6 @@ const ({double lat, double lng}) fallbackPlace = (lat: 25.0330, lng: 121.5654); Town? observerTown(BuildContext context) { final directory = context.read(); final regions = context.watch(); - final code = switch (regions.selected) { - SavedArea(:final code) => code, - CurrentArea(:final code) => code, - NationwideArea() => null, - }; - return directory.byCode(code ?? regions.currentCode) ?? + return directory.byCode(regions.selectedCode ?? regions.currentCode) ?? directory.nearest(fallbackPlace.lat, fallbackPlace.lng); } diff --git a/lib/features/data/presentation/pages/moon_page.dart b/lib/features/data/presentation/pages/moon_page.dart index e370379f0..72ce1e7bf 100644 --- a/lib/features/data/presentation/pages/moon_page.dart +++ b/lib/features/data/presentation/pages/moon_page.dart @@ -31,11 +31,8 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/astro/moon_orientation.dart'; import 'package:dpip/core/astro/moon_phase.dart'; import 'package:dpip/core/astro/moon_rise_set.dart'; -import 'package:dpip/core/geo/town.dart'; -import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/realtime/app_time.dart'; -import 'package:dpip/core/settings/home_area.dart'; -import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/features/data/presentation/observer_place.dart'; import 'package:dpip/features/data/presentation/widgets/moon_calendar.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_layer.dart'; @@ -44,7 +41,6 @@ import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; /// Taiwan's fixed offset — the wall clock every date on this page is read in. const Duration _taiwanOffset = Duration(hours: 8); @@ -58,10 +54,6 @@ const int _daysEitherSide = 31; /// consecutive frames still look different. const int _stepHours = 2; -/// Used only when no township is known. Taipei City Hall; resolved through the -/// directory so the page names a real township rather than a coordinate. -const ({double lat, double lng}) _fallbackPlace = (lat: 25.0330, lng: 121.5654); - class MoonPage extends StatefulWidget { const MoonPage({super.key}); @@ -203,27 +195,12 @@ class _MoonPageState extends State { _select(_indexAt(target)); } - /// The township rise and set are computed for: the current GPS township, or - /// the selected saved one, or the nearest to [_fallbackPlace]. - Town? _observer(BuildContext context) { - final directory = context.read(); - final regions = context.watch(); - final selected = regions.selected; - final code = switch (selected) { - SavedArea(:final code) => code, - CurrentArea(:final code) => code, - NationwideArea() => null, - }; - return directory.byCode(code ?? regions.currentCode) ?? - directory.nearest(_fallbackPlace.lat, _fallbackPlace.lng); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final phase = MoonPhase.at(_selected); final libration = MoonPhase.librationAt(_selected); - final town = _observer(context); + final town = observerTown(context); final local = _selectedLocal; final riseSet = town == null ? null diff --git a/lib/features/disaster_map/data/dpm_tile_prefetcher.dart b/lib/features/disaster_map/data/dpm_tile_prefetcher.dart index 8613b4fbb..45a650d7a 100644 --- a/lib/features/disaster_map/data/dpm_tile_prefetcher.dart +++ b/lib/features/disaster_map/data/dpm_tile_prefetcher.dart @@ -4,6 +4,7 @@ library; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/features/disaster_map/domain/dpm_tile_contract.dart'; import 'package:dpip/shared/map/map_tile_warmer.dart'; /// Thin DPM wrapper over the shared warm spine ([MapTileWarmer]). @@ -33,7 +34,7 @@ class DpmTilePrefetcher { north: north, east: east, zoom: zoom, - maxZoom: 16, + maxZoom: dpmSourceMaxZoom.toInt(), logLabel: 'dpm-$layer', workingSet: 'dpm-$layer', ); diff --git a/lib/features/disaster_map/domain/dpm_tile_contract.dart b/lib/features/disaster_map/domain/dpm_tile_contract.dart new file mode 100644 index 000000000..bc4e9a250 --- /dev/null +++ b/lib/features/disaster_map/domain/dpm_tile_contract.dart @@ -0,0 +1,6 @@ +/// Highest DPM MVT level that carries distinct point features. +/// +/// Probed 2026-08-26 across 20 Taiwan cities/islands: AED, restroom, and +/// shelter had identical in-bounds feature sets at z15 and z16. Z14 still +/// omitted features, so MapLibre may overzoom z15 but must not stop lower. +const double dpmSourceMaxZoom = 15; diff --git a/lib/features/earthquake/domain/eew_estimator.dart b/lib/features/earthquake/domain/eew_estimator.dart index 17354ca02..b894ad31b 100644 --- a/lib/features/earthquake/domain/eew_estimator.dart +++ b/lib/features/earthquake/domain/eew_estimator.dart @@ -11,9 +11,20 @@ import 'package:dpip/features/earthquake/domain/wave_time.dart'; /// /// The arithmetic is algebraically identical to the original but faster: /// `pow(x, 2)` is replaced by `x * x` (bit-identical for finite values, no -/// `pow` call), repeated sub-expressions are hoisted, and the constant -/// `1.657 * exp(1.533 * mag)` factor is lifted out of the per-region loop. +/// `pow` call), repeated sub-expressions are hoisted, and the magnitude-only +/// factors are lifted out of the per-region loop. Two further identities are +/// applied at their own call sites — see [_pgvIntensity] and [waveTime]. +/// `test/features/earthquake/eew_estimator_test.dart` pins every output. abstract final class EewEstimator { + /// `log10(1.31)`. See [_pgvIntensity]. + static const double _log10Pgv600ToPgv = 0.11727129565576426; + + /// `10^-1.85`, the magnitude-independent half of the near-field `long` term. + static const double _tenPowMinus1p85 = 0.01412537544622754; + + /// `sqrt(3)`, the S-wave slowness ratio. See [waveTime]. + static const double _sqrt3 = 1.7320508075688772; + /// Estimated continuous intensity at [point] for an event of moment /// magnitude [magW] at [epicenter] and focal [depth] (km), via the PGV /// attenuation model. @@ -22,25 +33,39 @@ abstract final class EewEstimator { LatLng point, double depth, double magW, - ) { - final long = math.pow(10, 0.5 * magW - 1.85).toDouble() / 2; - final epicentralDistance = epicenter.distanceTo(point) / 1000; + ) => _pgvIntensity( + epicentralKm: epicenter.distanceTo(point) / 1000, + depth: depth, + magW: magW, + tenPowHalfMag: math.pow(10, 0.5 * magW).toDouble(), + ); + + /// [areaPgv]'s body, with the two values a caller already holds passed in: + /// the epicentral distance (the callers below have just paid for that + /// haversine) and `10^(0.5·magW)` (constant across an event's regions). + /// + /// The published model computes `pgv600 = 10^e`, scales it by 1.31, and + /// returns `2.68 + 1.72·log10(pgv)`. Since `log10(1.31·10^e)` is + /// `e + log10(1.31)`, the exponentiation and the logarithm cancel exactly: + /// one `pow` and one `log` disappear, and the result stops making a round + /// trip through a number that can span 10 orders of magnitude. + static double _pgvIntensity({ + required double epicentralKm, + required double depth, + required double magW, + required double tenPowHalfMag, + }) { + final long = tenPowHalfMag * _tenPowMinus1p85 / 2; final hypocentralDistance = - math.sqrt(depth * depth + epicentralDistance * epicentralDistance) - - long; + math.sqrt(depth * depth + epicentralKm * epicentralKm) - long; final x = math.max(hypocentralDistance, 3.0); - final gpv600 = math - .pow( - 10, - 0.58 * magW + - 0.0038 * depth - - 1.29 - - math.log(x + 0.0028 * math.pow(10, 0.5 * magW)) / math.ln10 - - 0.002 * x, - ) - .toDouble(); - final pgv = gpv600 * 1.31; - return 2.68 + 1.72 * math.log(pgv) / math.ln10; + final log10Pgv600 = + 0.58 * magW + + 0.0038 * depth - + 1.29 - + math.log(x + 0.0028 * tenPowHalfMag) / math.ln10 - + 0.002 * x; + return 2.68 + 1.72 * (log10Pgv600 + _log10Pgv600ToPgv); } /// Hypocentral distance (km) and estimated intensity at the user's location. @@ -55,7 +80,12 @@ abstract final class EewEstimator { final pga = 1.657 * math.exp(1.533 * mag) * math.pow(dist, -1.607); var intensity = Intensity.fromPga(pga); if (intensity >= 4.5) { - intensity = areaPgv(epicenter, user, depth, mag); + intensity = _pgvIntensity( + epicentralKm: surfaceDistance, + depth: depth, + magW: mag, + tenPowHalfMag: math.pow(10, 0.5 * mag).toDouble(), + ); } return (dist: dist, i: intensity); } @@ -74,6 +104,7 @@ abstract final class EewEstimator { // Depth- and magnitude-dependent factors are constant across regions. final pgaFactor = 1.657 * math.exp(1.533 * mag); final depthSquared = depth * depth; + final tenPowHalfMag = math.pow(10, 0.5 * mag).toDouble(); final regions = {}; var maxIntensity = 0.0; @@ -83,7 +114,15 @@ abstract final class EewEstimator { final pga = pgaFactor * math.pow(dist, -1.607); var i = Intensity.fromPga(pga); if (i >= 4.5) { - i = areaPgv(epicenter, centroid, depth, mag); + // The strong-shaking branch reuses this region's haversine rather than + // repeating it: over Taiwan's ~368 townships that is 368 avoided + // trig evaluations on the frame an alert upgrades. + i = _pgvIntensity( + epicentralKm: surfaceDistance, + depth: depth, + magW: mag, + tenPowHalfMag: tenPowHalfMag, + ); } if (i > maxIntensity) maxIntensity = i; regions[code] = (dist: dist, i: i); @@ -93,6 +132,13 @@ abstract final class EewEstimator { /// Analytic P/S travel-time estimate (seconds) for epicentral [distance] (km) /// and focal [depth] (km), using the layered-velocity ray approximation. + /// + /// The S ray is not traced. The model's S-wave gradient is the P gradient + /// divided by `sqrt(3)` in **both** terms — `g0/sqrt(3)` over + /// `G/sqrt(3)` — so the ratio that fixes the ray's geometry is the same + /// number, the circle centre and both ray angles come out identical, and the + /// S time is the P time times `sqrt(3)`. Tracing it separately cost two + /// `atan`, two `tan` and a `log` to arrive at that multiplication. static WaveTime waveTime(double depth, double distance) { final za = depth; final xb = distance; @@ -119,19 +165,9 @@ abstract final class EewEstimator { final thetaB = math.atan(-zc / (xb - xc)); var ptime = (1 / bigG) * math.log(math.tan(thetaA / 2) / math.tan(thetaB / 2)); - - final sqrt3 = math.sqrt(3); - final g0s = g0 / sqrt3; - final gs = bigG / sqrt3; - final gsRatio = g0s / gs; - final zcs = -gsRatio; - final xcs = (xbSquared - 2 * gsRatio * za - zaSquared) / twoXb; - var thetaAs = math.atan((za - zcs) / xcs); - if (thetaAs < 0) thetaAs += math.pi; - thetaAs = math.pi - thetaAs; - final thetaBs = math.atan(-zcs / (xb - xcs)); - var stime = - (1 / gs) * math.log(math.tan(thetaAs / 2) / math.tan(thetaBs / 2)); + // Both caps read the *untraced* times, so the S time is derived before the + // P time is capped. + var stime = ptime * _sqrt3; if (distance / ptime > 7) ptime = distance / 7; if (distance / stime > 4) stime = distance / 4; diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index c4ed13d32..5885c4624 100644 --- a/lib/features/earthquake/presentation/pages/report_detail_page.dart +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -9,6 +9,7 @@ import 'dart:typed_data'; import 'package:dpip/app/theme/app_motion.dart'; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/geo/town.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/logging/log.dart'; @@ -32,6 +33,7 @@ import 'package:dpip/shared/map/map_gsi_overlay.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:dpip/shared/widgets/error_view.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/seismic/report_colors.dart'; import 'package:dpip/shared/widgets/async_view.dart'; @@ -71,6 +73,39 @@ class ReportDetailPage extends StatefulWidget { class _ReportDetailPageState extends State { final ValueNotifier _sheetExpanded = ValueNotifier(false); + /// Guards the bounce below — the error builder runs on every rebuild, and a + /// second `pop` would take the list away too. + bool _leaving = false; + + /// Sends the reader back to the list when the report does not exist. + /// + /// A 404 here is a real case, not a defensive one: a notification deep-links + /// by id, and CWA withdraws and renumbers reports. Leaving a retry button on + /// screen for it is offering an action that cannot ever succeed. + /// + /// Only 404. A timeout or a 5xx keeps the retry, because those do come back. + void _leaveForList() { + if (_leaving) return; + _leaving = true; + // After the frame: this runs from inside a build, where navigating is not + // allowed. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.maybeOf(context); + final router = GoRouter.of(context); + // The report route is nested under the list, so `pop` lands there — the + // deep-linked stack has the list under it. `goNamed` covers the case + // where it somehow does not. + if (router.canPop()) { + router.pop(); + } else { + router.goNamed(AppRoutes.earthquake); + } + messenger?.showSnackBar(SnackBar(content: Text(l10n.reportNotFound))); + }); + } + @override void dispose() { _sheetExpanded.dispose(); @@ -90,6 +125,20 @@ class _ReportDetailPageState extends State { report: report, sheetExpanded: _sheetExpanded, ), + error: (context, failure, retry) { + if (failure is NotFoundFailure) { + Log.warning( + 'report ${widget.reportId} not found — returning to the list', + ); + _leaveForList(); + return const Center(child: CircularProgressIndicator()); + } + // Everything else keeps AsyncView's own retry state. + return ErrorView( + headline: AppLocalizations.of(context).commonFetchFailed, + onRetry: retry, + ); + }, ), ), Positioned( diff --git a/lib/features/earthquake/presentation/widgets/eew_card.dart b/lib/features/earthquake/presentation/widgets/eew_card.dart index bc5eef284..13854832b 100644 --- a/lib/features/earthquake/presentation/widgets/eew_card.dart +++ b/lib/features/earthquake/presentation/widgets/eew_card.dart @@ -27,7 +27,6 @@ import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/models/lat_lng.dart'; import 'package:dpip/core/realtime/app_time.dart'; -import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; @@ -133,12 +132,7 @@ class _EewCardContentState extends State with SecondTicker { // 全國 (or 所在地 without a GPS fix) has no point to estimate for, so the // local tiles drop rather than invent one. GPS-first matches legacy, which // estimated from `GlobalProviders.location.coordinates`. - final store = context.watch(); - final code = switch (store.selected) { - SavedArea(:final code) => code, - CurrentArea(:final code) => code, - NationwideArea() => null, - }; + final code = context.watch().selectedCode; final town = code == null ? null : context.read().byCode(code); diff --git a/lib/features/events/presentation/pages/events_page.dart b/lib/features/events/presentation/pages/events_page.dart index 79fa47137..f88687294 100644 --- a/lib/features/events/presentation/pages/events_page.dart +++ b/lib/features/events/presentation/pages/events_page.dart @@ -76,13 +76,6 @@ class _EventsPageState extends State { message: AppLocalizations.of(context).regionCurrentUnavailable, ); } - return EventTimeline( - regionCode: switch (area) { - NationwideArea() => null, - CurrentArea(:final code) => code, - SavedArea(:final code) => code, - }, - refreshSignal: _refresh, - ); + return EventTimeline(regionCode: area.code, refreshSignal: _refresh); } } diff --git a/lib/features/home/presentation/home_active_events_controller.dart b/lib/features/home/presentation/home_active_events_controller.dart index 20496e4ee..150637ca8 100644 --- a/lib/features/home/presentation/home_active_events_controller.dart +++ b/lib/features/home/presentation/home_active_events_controller.dart @@ -36,11 +36,7 @@ class HomeActiveEventsController extends ChangeNotifier { Failure? get failure => _failure; /// Null → nationwide realtime list; otherwise the township code. - String? get _regionCode => switch (_regions.selected) { - NationwideArea() => null, - CurrentArea(:final code) => code, - SavedArea(:final code) => code, - }; + String? get _regionCode => _regions.selectedCode; /// Cache key: distinguishes 全國 (`''`) from a missing GPS township (`null` /// CurrentArea) so a locate-then-lose cycle still refetches. diff --git a/lib/features/home/presentation/home_weather_controller.dart b/lib/features/home/presentation/home_weather_controller.dart index 470dcfcaa..c51a2bde1 100644 --- a/lib/features/home/presentation/home_weather_controller.dart +++ b/lib/features/home/presentation/home_weather_controller.dart @@ -8,7 +8,6 @@ import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/logging/log.dart'; -import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; @@ -86,11 +85,7 @@ class HomeWeatherController extends ChangeNotifier { /// The township code driving the weather. Null for 全國 (no point weather) and /// for 所在地 without a GPS fix. - String? get areaCode => switch (_regions.selected) { - SavedArea(:final code) => code, - CurrentArea(:final code) => code, - NationwideArea() => null, - }; + String? get areaCode => _regions.selectedCode; /// Re-fetches the current area even though it has not changed — the /// pull-to-refresh / tab-reappear entry point. diff --git a/lib/features/home/presentation/widgets/home_eew_section.dart b/lib/features/home/presentation/widgets/home_eew_section.dart index 2ddd6c212..d83c52787 100644 --- a/lib/features/home/presentation/widgets/home_eew_section.dart +++ b/lib/features/home/presentation/widgets/home_eew_section.dart @@ -25,7 +25,6 @@ import 'package:dpip/core/models/lat_lng.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; -import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; @@ -173,12 +172,7 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { // selected township's centroid — same resolution the home weather uses. // 全國 (or 所在地 without a GPS fix) has no point to estimate for, so the // local tiles drop rather than invent one. - final store = context.watch(); - final code = switch (store.selected) { - SavedArea(:final code) => code, - CurrentArea(:final code) => code, - NationwideArea() => null, - }; + final code = context.watch().selectedCode; final town = code == null ? null : context.read().byCode(code); diff --git a/lib/features/home/presentation/widgets/home_map_backdrop.dart b/lib/features/home/presentation/widgets/home_map_backdrop.dart index f2c79c8f3..66a37abac 100644 --- a/lib/features/home/presentation/widgets/home_map_backdrop.dart +++ b/lib/features/home/presentation/widgets/home_map_backdrop.dart @@ -16,6 +16,7 @@ import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/map_cache.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/map/raster_frame_source.dart'; import 'package:dpip/shared/widgets/region_bar.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -222,11 +223,7 @@ class _HomeMapBackdropState extends State HomeMonitorBanner.height; final boundariesFuture = context.read>(); final selected = _regions?.selected; - final code = switch (selected) { - SavedArea(:final code) => code, - CurrentArea(:final code) => code, - _ => null, - }; + final code = selected?.code; final styleEpoch = _styleEpoch; // 所在地 is a *place*, not a region: the current GPS fix is the point, so // the camera centres on it (a fixed span around the fix) instead of framing @@ -391,7 +388,7 @@ class _HomeMapBackdropState extends State await _removeSourceQuietly(controller, _radarSource); await controller.addSource( _radarSource, - RasterSourceProperties(tiles: [radar.tileUrl(latest)], tileSize: 256), + rasterFrameSourceProperties(radar, latest), ); await controller.addRasterLayer( _radarSource, diff --git a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart index 53c9ee84c..a27d4ba94 100644 --- a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart +++ b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart @@ -303,7 +303,8 @@ class CardWaterField { weight[i] += bodyW[i]; } } - final pairs = _buildPairs(diameter, weight); + _buildPairs(diameter, weight); + final pairCount = _pairCount; // SolvePressure. final criticalVelocity = diameter * invDt; @@ -324,12 +325,14 @@ class CardWaterField { if (bodyW[i] == 0) continue; _p.vy[i] -= vpp * bodyW[i] * (accum[i] + ppw * bodyW[i]); } - for (final c in pairs) { - final f = vpp * c.w * (accum[c.a] + accum[c.b]); - _p.vx[c.a] -= f * c.nx; - _p.vy[c.a] -= f * c.ny; - _p.vx[c.b] += f * c.nx; - _p.vy[c.b] += f * c.ny; + for (var k = 0; k < pairCount; k++) { + final a = _pairA[k], b = _pairB[k]; + final nx = _pairNx[k], ny = _pairNy[k]; + final f = vpp * _pairW[k] * (accum[a] + accum[b]); + _p.vx[a] -= f * nx; + _p.vy[a] -= f * ny; + _p.vx[b] += f * nx; + _p.vy[b] += f * ny; } // SolveDamping. @@ -356,16 +359,20 @@ class CardWaterField { ); _p.vy[i] += damp * vn; } - for (final c in pairs) { - final vn = - (_p.vx[c.b] - _p.vx[c.a]) * c.nx + (_p.vy[c.b] - _p.vy[c.a]) * c.ny; + for (var k = 0; k < pairCount; k++) { + final a = _pairA[k], b = _pairB[k]; + final nx = _pairNx[k], ny = _pairNy[k]; + final vn = (_p.vx[b] - _p.vx[a]) * nx + (_p.vy[b] - _p.vy[a]) * ny; if (vn >= 0) continue; - final damp = math.max(_dampingStrength * c.w, math.min(-quad * vn, 0.5)); + final damp = math.max( + _dampingStrength * _pairW[k], + math.min(-quad * vn, 0.5), + ); final f = damp * vn; - _p.vx[c.a] += f * c.nx; - _p.vy[c.a] += f * c.ny; - _p.vx[c.b] -= f * c.nx; - _p.vy[c.b] -= f * c.ny; + _p.vx[a] += f * nx; + _p.vy[a] += f * ny; + _p.vx[b] -= f * nx; + _p.vy[b] -= f * ny; } // LimitVelocity — one diameter of travel per sub-step. @@ -441,23 +448,50 @@ class CardWaterField { static const double _particleMass = 1.0 / _particleInvMass; static const double _b2ParticleStride = 0.75; - /// Contact pairs within one diameter, with their weights and normals. - List<_Contact> _buildPairs(double diameter, Float32List weight) { - final out = _pairsScratch..clear(); - if (_live < 2) return out; + /// Contact pairs within one diameter, with their weights and normals, into + /// [_pairA]…[_pairNy] and [_pairCount]. + /// + /// Runs five times per 20 ms tick — 250 times a second while it rains — so + /// nothing here may allocate. The previous version built a + /// `Map>` bucket index and one `_Contact` object per pair on + /// every call; at the 200-drop cap that is on the order of a hundred + /// thousand short-lived objects a second, which is young-generation + /// collections landing inside frames rather than between them. + /// + /// The grid is the same uniform hash, expressed as an open-addressed slot + /// table plus a per-cell singly linked list over the particle indices. Cells + /// are filled by walking **i downwards** and pushing to the front, so each + /// chain comes out in ascending i — the exact order `List.add` produced. The + /// solver accumulates into velocities pair by pair and floating-point + /// addition does not commute, so that ordering is part of the result, not an + /// implementation detail. + void _buildPairs(double diameter, Float32List weight) { + _pairCount = 0; + if (_live < 2) return; final d2Max = diameter * diameter; - final buckets = >{}; + + _ensureGrid(); + final cellX = _cellXScratch; + final cellY = _cellYScratch; for (var i = 0; i < _live; i++) { - (buckets[_cellKey(_p.x[i], _p.y[i], diameter)] ??= []).add(i); + cellX[i] = (_p.x[i] / diameter).floor(); + cellY[i] = (_p.y[i] / diameter).floor(); } + _clearGrid(); + for (var i = _live - 1; i >= 0; i--) { + final slot = _slotFor(_key(cellX[i], cellY[i])); + _nextInCell[i] = _cellHead[slot]; + _cellHead[slot] = i; + } + for (var i = 0; i < _live; i++) { - final cx = (_p.x[i] / diameter).floor(); - final cy = (_p.y[i] / diameter).floor(); + final cx = cellX[i]; + final cy = cellY[i]; for (var ox = -1; ox <= 1; ox++) { for (var oy = -1; oy <= 1; oy++) { - final cell = buckets[_key(cx + ox, cy + oy)]; - if (cell == null) continue; - for (final j in cell) { + final slot = _findSlot(_key(cx + ox, cy + oy)); + if (slot < 0) continue; + for (var j = _cellHead[slot]; j >= 0; j = _nextInCell[j]) { if (j <= i) continue; final dx = _p.x[j] - _p.x[i]; final dy = _p.y[j] - _p.y[i]; @@ -467,16 +501,108 @@ class CardWaterField { final w = 1.0 - d / diameter; weight[i] += w; weight[j] += w; - out.add(_Contact(i, j, w, dx / d, dy / d)); + final k = _pairCount; + if (k == _pairA.length) _growPairs(); + _pairA[k] = i; + _pairB[k] = j; + _pairW[k] = w; + _pairNx[k] = dx / d; + _pairNy[k] = dy / d; + _pairCount = k + 1; } } } } - return out; } - /// Contact list reused across iterations — cleared, never reallocated. - final List<_Contact> _pairsScratch = <_Contact>[]; + // --- contact scratch (structure of arrays, grown, never per-call) -------- + Int32List _pairA = Int32List(0); + Int32List _pairB = Int32List(0); + // The solver reads these at full precision; Float32 would round the normals + // and change the trajectory. + Float64List _pairW = Float64List(0); + Float64List _pairNx = Float64List(0); + Float64List _pairNy = Float64List(0); + int _pairCount = 0; + + void _growPairs() { + final next = _pairA.isEmpty ? 256 : _pairA.length * 2; + _pairA = Int32List(next)..setRange(0, _pairA.length, _pairA); + _pairB = Int32List(next)..setRange(0, _pairB.length, _pairB); + _pairW = Float64List(next)..setRange(0, _pairW.length, _pairW); + _pairNx = Float64List(next)..setRange(0, _pairNx.length, _pairNx); + _pairNy = Float64List(next)..setRange(0, _pairNy.length, _pairNy); + } + + // --- neighbour grid ----------------------------------------------------- + Int32List _cellXScratch = Int32List(0); + Int32List _cellYScratch = Int32List(0); + Int32List _nextInCell = Int32List(0); + + /// Slot table: `_slotKey[s] + 1` (0 marks an empty slot) and the head of + /// that cell's chain. Sized to the drop cap, so it is allocated once. + Int32List _slotKey = Int32List(0); + Int32List _cellHead = Int32List(0); + + /// The slots touched this call, so clearing costs O(live), not O(table). + Int32List _usedSlots = Int32List(0); + int _usedSlotCount = 0; + int _slotMask = 0; + + void _ensureGrid() { + if (_cellXScratch.length >= capacity && _slotMask != 0) return; + _cellXScratch = Int32List(capacity); + _cellYScratch = Int32List(capacity); + _nextInCell = Int32List(capacity); + _usedSlots = Int32List(capacity); + // Power-of-two table at =< 50 % load, so the linear probe stays short. + var size = 16; + while (size < capacity * 2) { + size <<= 1; + } + _slotKey = Int32List(size); + _cellHead = Int32List(size); + _slotMask = size - 1; + } + + void _clearGrid() { + for (var n = 0; n < _usedSlotCount; n++) { + _slotKey[_usedSlots[n]] = 0; + } + _usedSlotCount = 0; + } + + /// Fibonacci hash of a packed cell key, masked to the table. + static int _hash(int key) => (key * 0x9E3779B1) & 0x3FFFFFFF; + + /// The slot holding [key], claiming a free one if it is not present yet. + int _slotFor(int key) { + final stored = key + 1; + var s = _hash(key) & _slotMask; + while (true) { + final k = _slotKey[s]; + if (k == stored) return s; + if (k == 0) { + _slotKey[s] = stored; + _cellHead[s] = -1; + _usedSlots[_usedSlotCount++] = s; + return s; + } + s = (s + 1) & _slotMask; + } + } + + /// The slot holding [key], or -1 when the cell is empty. + int _findSlot(int key) { + final stored = key + 1; + var s = _hash(key) & _slotMask; + while (true) { + final k = _slotKey[s]; + if (k == stored) return s; + if (k == 0) return -1; + s = (s + 1) & _slotMask; + } + } /// the emitter's `particleSystem.setRadius(0.01)`, in world units. static const double _particleRadius = 0.01; @@ -487,9 +613,6 @@ class CardWaterField { /// affected. static const double accumulationScale = 0x40 / 0xFF; - static int _cellKey(double x, double y, double cell) => - _key((x / cell).floor(), (y / cell).floor()); - /// Packs a cell coordinate into one int. The offset keeps negatives (drops /// above the edge) on distinct keys. static int _key(int cx, int cy) => (cx + 4096) * 8192 + (cy + 4096); @@ -988,12 +1111,3 @@ Future _imageFromRgba(Uint8List rgba, int size) { } /// One particle-particle contact: indices, weight and the A→B unit normal. -class _Contact { - const _Contact(this.a, this.b, this.w, this.nx, this.ny); - - final int a; - final int b; - final double w; - final double nx; - final double ny; -} diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart index 0ede40843..a39c5a961 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart @@ -300,7 +300,9 @@ class WeatherSkyPainter extends CustomPainter { // The sun's direction swings across the sky with its height, so lit faces // move around the cloud through the day. final day = frame.dayAmount; - final sunDir = [-0.55 + 1.1 * day, 0.25 + 0.70 * day, 0.45]; + final sunDirX = -0.55 + 1.1 * day; + final sunDirY = 0.25 + 0.70 * day; + const sunDirZ = 0.45; // The base and haze sky probes are pixel-independent, so their colours are // read once per frame from the CPU LUT readback rather than fetched on @@ -317,64 +319,74 @@ class WeatherSkyPainter extends CustomPainter { fallback; final hazeSky = lutCache.skyAt(lutU, _skyAtV(0.95)) ?? fallback; + // Only five of the forty-three uniform slots differ between instances — + // the sprite's size, its opacity, and the texture's own size. The rest + // describe the frame, so they are written once here instead of forty + // times a frame through a closure allocated per sprite. A draw call + // snapshots the shader's uniforms, so a slot left from the previous + // instance is the value that instance drew with. + shader + ..setFloat(2, sunDirX) + ..setFloat(3, sunDirY) + ..setFloat(4, sunDirZ) + // Ground bounce, from below. + ..setFloat(5, 0.0) + ..setFloat(6, -1.0) + ..setFloat(7, 0.2) + // Ambient, from above. + ..setFloat(8, 0.0) + ..setFloat(9, 1.0) + ..setFloat(10, 0.1) + ..setFloat(11, lighting.base.$1) + ..setFloat(12, lighting.base.$2) + ..setFloat(13, lighting.base.$3) + ..setFloat(14, lighting.sun.$1) + ..setFloat(15, lighting.sun.$2) + ..setFloat(16, lighting.sun.$3) + ..setFloat(17, lighting.ground.$1) + ..setFloat(18, lighting.ground.$2) + ..setFloat(19, lighting.ground.$3) + ..setFloat(20, lighting.ambient.$1) + ..setFloat(21, lighting.ambient.$2) + ..setFloat(22, lighting.ambient.$3) + ..setFloat(24, 0.05) // start edge + ..setFloat(25, 0.23) // end edge + ..setFloat(26, 1.0) // progress — deck fully revealed + ..setFloat(27, 0.35) // smooth + ..setFloat(28, 0.85) // cloud depth + ..setFloat(29, day) + ..setFloat(30, frame.fog * 0.5) + ..setFloat(31, frame.lightning.flash * 0.8) + ..setFloat(32, lighting.whitePer) + // iLutSize. NOTE: `clouds.frag` reads `iLutSize.y`, and slot 34 is that + // `.y` — so the shader's manual bilinear divides by 1.0, not by the + // column's 141 rows. Carried over verbatim from the per-sprite version + // rather than corrected here: swapping these two changes what the cloud + // deck looks like, which is a rendering decision, not a refactor. + ..setFloat(33, SkyLutCache.skyViewSize.height) + ..setFloat(34, 1.0) + ..setFloat(37, baseSky.r) + ..setFloat(38, baseSky.g) + ..setFloat(39, baseSky.b) + ..setFloat(40, hazeSky.r) + ..setFloat(41, hazeSky.g) + ..setFloat(42, hazeSky.b) + ..setImageSampler(1, skyColumn); + + final paint = Paint()..shader = shader; for (final p in placed) { final sprite = cloudSprites[p.sprite % cloudSprites.length]; - var i = 0; - void set(double v) => shader.setFloat(i++, v); - set(p.width); - set(p.height); - set(sunDir[0]); - set(sunDir[1]); - set(sunDir[2]); - set(0.0); - set(-1.0); - set(0.2); // ground bounce, from below - set(0.0); - set(1.0); - set(0.1); // ambient, from above - set(lighting.base.$1); - set(lighting.base.$2); - set(lighting.base.$3); - set(lighting.sun.$1); - set(lighting.sun.$2); - set(lighting.sun.$3); - set(lighting.ground.$1); - set(lighting.ground.$2); - set(lighting.ground.$3); - set(lighting.ambient.$1); - set(lighting.ambient.$2); - set(lighting.ambient.$3); - set(p.opacity); - set(0.05); // start edge - set(0.23); // end edge - set(1.0); // progress — deck fully revealed - set(0.35); // smooth - set(0.85); // cloud depth - set(day); - set(frame.fog * 0.5); - set(frame.lightning.flash * 0.8); - set(lighting.whitePer); - // The LUT sampler is the CPU-extracted column: `.y` is its height (141) - // and `.x` is unused by the shader. - set(SkyLutCache.skyViewSize.height); - set(1.0); - set(sprite.width.toDouble()); - set(sprite.height.toDouble()); - set(baseSky.r); - set(baseSky.g); - set(baseSky.b); - set(hazeSky.r); - set(hazeSky.g); - set(hazeSky.b); - shader.setImageSampler(0, sprite); - shader.setImageSampler(1, skyColumn); + shader + ..setFloat(0, p.width) + ..setFloat(1, p.height) + ..setFloat(23, p.opacity) + ..setFloat(35, sprite.width.toDouble()) + ..setFloat(36, sprite.height.toDouble()) + ..setImageSampler(0, sprite); canvas.save(); canvas.translate(p.left, p.top); - canvas.drawRect( - Rect.fromLTWH(0, 0, p.width, p.height), - Paint()..shader = shader, - ); + canvas.drawRect(Rect.fromLTWH(0, 0, p.width, p.height), paint); canvas.restore(); } } diff --git a/lib/features/map/presentation/layers/disaster_map_layer.dart b/lib/features/map/presentation/layers/disaster_map_layer.dart index 214cc516f..705cea198 100644 --- a/lib/features/map/presentation/layers/disaster_map_layer.dart +++ b/lib/features/map/presentation/layers/disaster_map_layer.dart @@ -11,6 +11,7 @@ import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/features/disaster_map/domain/aed_detail.dart'; import 'package:dpip/features/disaster_map/domain/disaster_map_repository.dart'; +import 'package:dpip/features/disaster_map/domain/dpm_tile_contract.dart'; import 'package:dpip/features/disaster_map/domain/restroom_detail.dart'; import 'package:dpip/features/disaster_map/domain/shelter_detail.dart'; import 'package:dpip/features/map/presentation/widgets/disaster_map_overlay_menu.dart'; @@ -324,7 +325,11 @@ class DisasterMapLayer with MapLayerDefaults implements MapLayer { await controller.addSource( sub.sourceId, - VectorSourceProperties(tiles: [tileUrl], minzoom: 0, maxzoom: 16), + VectorSourceProperties( + tiles: [tileUrl], + minzoom: 0, + maxzoom: dpmSourceMaxZoom, + ), ); final markerId = _markerImageId(sub.id); diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 77070d0b6..3565c3d59 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -142,6 +142,12 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { final Future _boxGridFuture; final TownDirectory _townDirectory; + /// Township centroids, keyed by code — built once from the bundled + /// directory, which does not change while the app runs. Rebuilding it per + /// alert serial meant 368 map entries and 368 `LatLng`s allocated on the + /// frame an alert arrives, which is the frame with the least to spare. + Map? _centroids; + Map _stations = const {}; MapLibreMapController? _controller; bool _listening = false; @@ -778,7 +784,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { epicenter: eew.info.latlng, depth: eew.info.depth, mag: eew.info.magnitude, - regionCentroids: { + regionCentroids: _centroids ??= { for (final town in _townDirectory.all) town.code: geo.LatLng(town.lat, town.lng), }, diff --git a/lib/features/map/presentation/layers/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index f6cd34fd2..e823d12a1 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -35,6 +35,7 @@ import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/map/raster_frame_source.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -984,9 +985,9 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { Log.warning('Typhoon weather overlay: no ${kind.name} frame ≤ $bulletin'); return; } - final url = kind == TyphoonWeatherOverlay.radar - ? radar.tileUrl('$frame') - : satellite.tileUrl('$frame'); + final RasterFrameSource frameSource = kind == TyphoonWeatherOverlay.radar + ? radar + : satellite; // Warm ambient via ApiClient before MapLibre races its own GETs. try { final bounds = await controller.getVisibleRegion(); @@ -1023,7 +1024,7 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { } catch (_) {} await controller.addSource( _wxSrc, - RasterSourceProperties(tiles: [url], tileSize: 256), + rasterFrameSourceProperties(frameSource, '$frame'), ); // Sit under the bottom typhoon fill so vectors/warning stay on top. final below = _warningNames.isNotEmpty ? _warnLyr : _probLyr; diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart index 8afec3eca..13d173e55 100644 --- a/lib/features/map/presentation/layers/wind_forecast_layer.dart +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -9,7 +9,6 @@ import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/settings/map_reference_outline_controller.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'; @@ -29,7 +28,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// 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 — native on Android and iOS -/// ([WindParticleNative]), [WindParticleOverlay] elsewhere — driven by the +/// ([WindParticleNative]) — driven by the /// loaded [field]. /// /// The tiles are a semi-transparent speed wash, so the layer draws its own @@ -56,7 +55,7 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { /// The GPU renderer that draws the particles inside the map. /// - /// Where it is available it replaces [WindParticleOverlay] entirely. On + /// Where it is available the map draws the particles itself. 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 @@ -191,16 +190,21 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { particles.setSurfaceVisible(visible); } - /// The Flutter overlay, only where the map cannot draw the particles itself. + /// Nothing. The particles are map content now, on every platform. /// - /// 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. + /// There used to be a Flutter-drawn CPU fallback here for anywhere the + /// native layer could not run. It is gone, and its absence is deliberate: + /// two renderers behind one feature name meant a platform could quietly be + /// showing something else entirely. Days were spent comparing "iOS" against + /// "Android" before the logs revealed that the iOS side was the Simulator + /// running the fallback while Android ran the real one — the two were never + /// the same picture, and no amount of tuning was going to align them. + /// + /// Where the native layer cannot run, the field renders without particles. + /// The colour ramp still carries the wind speed, which is the information; + /// a wrong animation is worse than none. @override - Widget buildMapOverlay(BuildContext context) => particles.isActive - ? const SizedBox.shrink() - : WindParticleOverlay(layer: this); + Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); /// 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 index e6f464212..e6a5a72e4 100644 --- a/lib/features/map/presentation/layers/wind_particle_native.dart +++ b/lib/features/map/presentation/layers/wind_particle_native.dart @@ -145,12 +145,16 @@ class WindParticleNative { 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. + /// The animation runs only when there is something to animate, someone to + /// see it, and the camera is stable. Pausing during a gesture prevents a + /// state pair sampled under different projections from becoming a streak; + /// native clears its trail buffers before resuming. Future _pushPlaying(MapLibreMapController controller) async { - final want = _visible && _uploaded?.source != null; + final want = windParticleShouldPlay( + visible: _visible, + interacting: interacting.value, + field: _uploaded, + ); if (want == _playing) return; _playing = want; await controller.setWindParticlePlaying(want); @@ -179,6 +183,14 @@ class WindParticleNative { } } +/// Whether the native render loop should currently advance its GPU state. +@visibleForTesting +bool windParticleShouldPlay({ + required bool visible, + required bool interacting, + required WindField? field, +}) => visible && !interacting && field?.source != null; + /// The display scale the point size is expressed in. /// /// `pointSize` is tuned in logical pixels, but `gl_PointSize` is in physical @@ -203,8 +215,12 @@ Map windParticleTuning({double pixelRatio = 1}) => { 'zoomHi': kWindZoomHi, 'particlesLo': kWindParticles.$1, 'particlesHi': kWindParticles.$2, - 'pointSizeLo': kWindPointSize.$1, - 'pointSizeHi': kWindPointSize.$2, + 'lineWidthZ3': kWindLineWidths[0], + 'lineWidthZ4': kWindLineWidths[1], + 'lineWidthZ5': kWindLineWidths[2], + 'lineWidthZ6': kWindLineWidths[3], + 'lineWidthZ7': kWindLineWidths[4], + 'particleWidth': kWindParticleWidth, 'speedFactorLo': kWindSpeedFactor.$1, 'speedFactorHi': kWindSpeedFactor.$2, 'fadeOpacityLo': kWindFadeOpacity.$1, diff --git a/lib/features/map/presentation/layers/wind_particle_sim.dart b/lib/features/map/presentation/layers/wind_particle_sim.dart index d701e8d07..efb22fd8c 100644 --- a/lib/features/map/presentation/layers/wind_particle_sim.dart +++ b/lib/features/map/presentation/layers/wind_particle_sim.dart @@ -36,9 +36,36 @@ const double kWindZoomHi = 7; // `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) 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 +// Windy's default blending is 1.0. Mobile multiplies it by 1.06, and the +// renderer's `0.9 + 0.5 * (blending - 0.92)` formula therefore gives 0.97. +// DPIP only runs this native renderer on mobile, so there is no desktop branch +// to preserve here. +/// What fraction of the accumulated trail survives each 60 Hz step. +/// +/// 0.85, not the reference's 0.97, and the difference is a deliberate choice +/// about what a streak should look like rather than a correction. +/// +/// Windy's 0.97 has a 46-step half life, so a stroke stays visible for most of +/// a second and the field reads as long continuous flow lines. The Flutter +/// overlay this app used to fall back to drew something else entirely: the last +/// **14 frames** of dot positions with a quadratic alpha ramp, which reads as +/// short bright dashes. That was the look chosen here. +/// +/// 0.85 puts the exponential where the 14-frame window was — `0.85^14 = 0.10`, +/// and the composite's black-point lift removes what is left — so the native +/// renderer produces the dash rather than the streak. +const (double, double) kWindFadeOpacity = (0.85, 0.85); // mobile, constant + +/// Windy's integer-zoom line-width table over this layer's z3–z7 range. +/// +/// This is not an endpoint curve: the reference has a deliberate kink at +/// every zoom (`1, 1.2, 1.6, 1.8, 2`). Collapsing it to z3/z7 endpoints makes +/// z5 only 1.5 and is visibly thinner than the source renderer. +const List kWindLineWidths = [1, 1.2, 1.6, 1.8, 2]; + +/// `glParticleWidth` from the reference wind product. +const double kWindParticleWidth = 1.3; /// Chance per frame that a particle in good standing is recycled anyway. const double kWindDropRate = 0.011; @@ -82,10 +109,31 @@ int particleCountFor(double zoom) { 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(kWindPointSize, zoom); +/// Base line width at [zoom], interpolated between the reference's exact +/// integer stops and clamped to this layer's supported range. +double lineWidthFor(double zoom) { + final z = zoom.clamp(kWindZoomLo, kWindZoomHi); + final lo = z.floor() - kWindZoomLo.toInt(); + final hi = z.ceil() - kWindZoomLo.toInt(); + if (lo == hi) return kWindLineWidths[lo]; + final f = z - z.floor(); + return kWindLineWidths[lo] * (1 - f) + kWindLineWidths[hi] * f; +} + +/// Full rendered stroke width in logical pixels. +/// +/// The reference first computes `widthFactor = max(1, lineWidth × 1.3 × DPR)` +/// and then grows the antialiased quad by one physical pixel. Keeping that +/// final pixel in physical space matters: adding one logical pixel made the +/// same wind conspicuously heavier on high-density phones. +double pointSizeFor(double zoom, {double pixelRatio = 1}) { + final ratio = pixelRatio <= 0 ? 1 : pixelRatio; + final widthFactor = math.max( + 1.0, + lineWidthFor(zoom) * kWindParticleWidth * ratio, + ); + return (widthFactor + 1) / ratio; +} /// What fraction of the trail buffer survives each frame. /// diff --git a/lib/features/map/presentation/widgets/monitor_eew_card.dart b/lib/features/map/presentation/widgets/monitor_eew_card.dart index 76bc653dc..c3e182c7c 100644 --- a/lib/features/map/presentation/widgets/monitor_eew_card.dart +++ b/lib/features/map/presentation/widgets/monitor_eew_card.dart @@ -20,7 +20,6 @@ import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/models/lat_lng.dart'; import 'package:dpip/core/realtime/app_time.dart'; -import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; @@ -99,12 +98,7 @@ class _MonitorEewCardState extends State with SecondTicker { // selected township's centroid — same resolution the home weather uses. // 全國 (or 所在地 without a GPS fix) has no point to estimate for, so the // local tiles drop rather than invent one. - final store = context.watch(); - final code = switch (store.selected) { - SavedArea(:final code) => code, - CurrentArea(:final code) => code, - NationwideArea() => null, - }; + final code = context.watch().selectedCode; final town = code == null ? null : context.read().byCode(code); diff --git a/lib/features/map/presentation/widgets/wind_particle_overlay.dart b/lib/features/map/presentation/widgets/wind_particle_overlay.dart deleted file mode 100644 index 2774a4ca3..000000000 --- a/lib/features/map/presentation/widgets/wind_particle_overlay.dart +++ /dev/null @@ -1,625 +0,0 @@ -/// The wind-forecast particle animation — a Flutter overlay that advects a -/// cloud of particles through the frame's wind field, mirroring the web demo's -/// GPU pass on the CPU. -library; - -import 'dart:async'; -import 'dart:math' as math; -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:dpip/core/logging/log.dart'; -import 'package:dpip/core/platform/device_info.dart'; -import 'package:dpip/core/platform/render_tier.dart'; -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'; - -/// Renders the ECMWF / GFS wind field as flowing streaks, not just a colour -/// wash. -/// -/// The map's overlay slot gives the layer a plain widget; this one owns the -/// animation — a [Ticker] steps the simulation every frame and a repaint -/// notifier redraws the [CustomPainter] — while the geometry (projection, -/// advection, recycling) lives in [WindParticleSim] where it is testable -/// without a widget tree. -/// -/// The camera comes from the live map controller every tick, so the streaks -/// track pan/zoom/rotate exactly; the trail buffer is screen-space, so it is -/// dropped on any camera change — a streak drawn for one view is wrong for the -/// next, and the web renderer's alternative is to smear the old one across the -/// pan. The ticker only runs while a wind field is loaded *and* the map tab is -/// the visible one — the shell keeps hidden tabs mounted, so without the -/// visibility check a wind field would keep animating (and rasterising full -/// screen) behind every other tab. -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 - State createState() => _WindParticleOverlayState(); -} - -class _WindParticleOverlayState extends State - with SingleTickerProviderStateMixin, WidgetsBindingObserver { - /// Marks the painter for redraw each frame without rebuilding the widget - /// tree — the per-frame path of this overlay. - final _OverlayRepaint _repaint = _OverlayRepaint(); - - late final Ticker _ticker = createTicker(_onTick); - - WindParticleSim? _sim; - WindCamera? _lastCamera; - final _TrailBuffer _trails = _TrailBuffer(); - - /// Whether the map is actually in front of the user — see [_syncVisibility]. - bool _visible = true; - - /// Timers keep running while Android has stopped scheduling Flutter frames. - /// Track the app edge separately so the watchdog cannot mistake a backgrounded - /// ticker for a wedged foreground animation and restart it into a dead state. - bool _appForeground = true; - - /// The shell's visible-tab notifier; `null` outside the shell means the - /// overlay is always visible. - VisibleTab? _visibleTab; - - /// Whether a finger is on the map — the field is hidden for the duration. - bool _interacting = false; - - /// Frames actually stepped, and why the last one was not — the watchdog's - /// only inputs. - int _steps = 0; - int _seenSteps = 0; - int _quietChecks = 0; - String? _stalledOn; - bool _stallLogged = false; - Timer? _watchdog; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _appForeground = _isForeground(WidgetsBinding.instance.lifecycleState); - widget.layer.field.addListener(_onField); - widget.layer.interacting.addListener(_onInteracting); - // Straight assignment: this runs inside the first build, where asking for - // another one throws. - _adoptField(); - _probeTier(); - } - - /// Device class decides how long the streaks are, which is now what this - /// overlay's cost scales with: each frame of tail is one more - /// `drawRawPoints` over the visible population. A shorter tail on the low - /// tier is a slightly shorter streak, not a coarser one. Fire-and-forget: - /// until the probe lands the full length stands in. - Future _probeTier() async { - RenderTier tier; - try { - tier = renderTierFor(await DeviceInfoService.load()); - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'Device tier probe failed'); - tier = RenderTier.high; - } - if (!mounted) return; - _trails.tailFrames = tier == RenderTier.low - ? 6 - : _TrailBuffer.historyFrames; - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // Subscribes to the notifier itself, not to an InheritedWidget rebuild: - // the shell hands every page the same [VisibleTab] instance, so waiting - // for a scope update would never re-run this (see VisibleTabScope's doc). - final visibleTab = VisibleTabScope.of(context); - if (identical(visibleTab, _visibleTab)) return; - _visibleTab?.removeListener(_syncVisibility); - _visibleTab = visibleTab; - visibleTab?.addListener(_syncVisibility); - _syncVisibility(); - } - - /// Stops the animation when the map goes out of view, starts it on return. - /// - /// The shell's IndexedStack keeps this overlay mounted behind other tabs, and - /// a pushed full-screen route leaves the branch index untouched, so neither - /// tears it down. Without this the simulation would keep stepping — and the - /// trail buffer keep rasterising — for a surface nobody is looking at. - void _syncVisibility() { - final visible = _visibleTab?.isOnScreen(MapPage.tabIndex) ?? true; - if (visible == _visible) return; - _visible = visible; - _updateTicker(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - final foreground = _isForeground(state); - if (foreground == _appForeground) return; - _appForeground = foreground; - _updateTicker(); - } - - static bool _isForeground(AppLifecycleState? state) => - state == null || - state == AppLifecycleState.resumed || - state == AppLifecycleState.inactive; - - /// 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 => - WindParticleOverlay.animateOnThisPlatform && - _sim != null && - _visible && - _appForeground && - !_interacting; - - /// Runs the ticker only while [_shouldAnimate]. - void _updateTicker() { - final shouldRun = _shouldAnimate; - if (shouldRun && !_ticker.isActive) { - _ticker.start(); - } else if (!shouldRun && _ticker.isActive) { - _ticker.stop(); - } - if (shouldRun) { - _watchdog ??= Timer.periodic(_watchdogPeriod, (_) => _checkAlive()); - } else { - _watchdog?.cancel(); - _watchdog = null; - _quietChecks = 0; - _stallLogged = false; - } - } - - /// How often the watchdog looks, and how many consecutive quiet looks count - /// as wedged. - /// - /// Two, not one: restarting drops the trail buffer, so a false positive is a - /// visible flicker. A healthy field produces sixty frames a second, so *zero* - /// across two full seconds is not a slow device — but a single quiet second - /// can be, on a frame the engine simply did not schedule. - static const Duration _watchdogPeriod = Duration(seconds: 1); - static const int _quietChecksBeforeRestart = 2; - - /// Restarts the animation if it should be running and is not. - /// - /// This exists because a frozen wind field has proven able to survive every - /// recovery the page offers — panning, zooming, scrubbing, leaving the tab - /// and coming back — and a permanently dead overlay is a far worse outcome - /// than a one-second hitch. Rather than depend on having found every way it - /// can wedge, this watches the one thing that matters (are frames being - /// produced?) and rebuilds the animation when they stop. - /// - /// It also logs *why*, once per stall: the reason names which guard is - /// holding, which is the difference between a report of "the particles froze" - /// and a fix. - void _checkAlive() { - if (!mounted) return; - final shouldRun = _shouldAnimate; - if (!shouldRun || _steps != _seenSteps) { - _seenSteps = _steps; - _quietChecks = 0; - _stallLogged = false; - return; - } - if (++_quietChecks < _quietChecksBeforeRestart) return; - - if (!_stallLogged) { - _stallLogged = true; - Log.warning( - 'wind particles stalled: ${_stalledOn ?? 'the ticker stopped firing'} ' - '(ticker active=${_ticker.isActive} muted=${_ticker.muted}, ' - 'controller=${widget.layer.mapController != null}, ' - 'field=${widget.layer.field.value != null}, visible=$_visible)', - ); - } - - // A muted ticker is the framework's call (the route is off-stage), not a - // fault — restarting it would fight the framework and change nothing. - if (_ticker.muted) return; - - // Rebuild the animation from scratch: a stopped or wedged ticker, a - // simulation whose field went missing, and a trail buffer that may hold a - // texture from before the stall. - _quietChecks = 0; - if (_ticker.isActive) _ticker.stop(); - _trails.clear(); - _lastCamera = null; - if (_sim == null) { - final field = widget.layer.field.value; - if (field != null) _sim = WindParticleSim(field); - } - _updateTicker(); - } - - /// The layer loaded a new field (frame scrub or first show) — swap the - /// simulation and start (or stop) the animation accordingly. - /// - /// This has to rebuild, because [_WindParticlePainter] is handed the - /// simulation by value when [build] runs. The field arrives asynchronously - /// and is therefore still null at [initState], so a painter built then holds - /// null and paints nothing however busily the ticker steps behind it. What - /// broke the illusion was that the first pan repainted the map surface and - /// rebuilt this overlay with it — so the animation appeared to need a nudge - /// to start, when what it needed was a build. - void _onField() { - if (!mounted) return; - setState(_adoptField); - } - - /// Clears the field for the duration of a gesture and reseeds it after. - /// - /// The whole overlay goes quiet in between — no stepping, no drawing, no - /// history — so a pan, pinch or rotate costs this page nothing at all. The - /// reseed then happens once, against the camera the gesture actually settled - /// on, rather than the field chasing a viewport that is still moving. - void _onInteracting() { - if (!mounted) return; - final interacting = widget.layer.interacting.value; - if (interacting == _interacting) return; - setState(() { - _interacting = interacting; - // A streak drawn before the gesture describes a view that is gone. - _trails.clear(); - _lastCamera = null; - if (interacting) { - _updateTicker(); - } else { - // Reseed from scratch: the population size follows zoom, and this is - // the first moment the zoom is final. - _adoptField(); - } - }); - } - - void _adoptField() { - final field = widget.layer.field.value; - _sim = field == null ? null : WindParticleSim(field); - // A new frame's streaks must not grow out of the previous frame's. - _trails.clear(); - _updateTicker(); - } - - /// Steps the simulation for one frame. - /// - /// **Nothing may escape this method.** [Ticker] reschedules itself *after* - /// the callback returns, so a single throw here stops the animation for the - /// rest of the session — and `isActive` still reports `true` afterwards, so - /// [_updateTicker] sees a healthy ticker and never restarts it. That is a - /// frozen wind field that no amount of panning, zooming or re-selecting the - /// layer can revive, from one bad frame. - /// - /// A bad frame is not hypothetical: `context.size` throws outright while the - /// render object is dirty for layout, which is exactly the window a rebuild - /// of this subtree opens. - void _onTick(Duration _) { - try { - _step(); - } catch (error, stackTrace) { - // Logged, not swallowed silently — but the next frame still runs. - Log.handle(error, stackTrace, 'wind particle tick'); - } - } - - void _step() { - final controller = widget.layer.mapController; - final sim = _sim; - // Each bail-out names itself. A frozen field looks identical from the - // outside whichever of these stopped it, and they need different fixes — - // a detached controller is a layer-lifecycle bug, a null field is a load - // that never landed, a null size is a layout that never happened. - if (!mounted) return _stall('unmounted'); - if (controller == null) return _stall('no map controller'); - if (sim == null) return _stall('no wind field'); - final position = controller.cameraPosition; - if (position == null) return _stall('no camera'); - final size = context.size; - if (size == null || size.isEmpty) return _stall('no size'); - - final previous = _lastCamera; - final target = position.target; - final unchanged = - previous != null && - previous.centerLat == target.latitude && - previous.centerLng == target.longitude && - previous.zoom == position.zoom && - previous.bearing == position.bearing; - final camera = unchanged - ? previous - : WindCamera( - centerLat: target.latitude, - centerLng: target.longitude, - zoom: position.zoom, - bearing: position.bearing, - ); - if (previous != null && !unchanged) { - _trails.clear(); - } - _lastCamera = camera; - - // The painter needs the zoom at paint time and the widget does not rebuild - // when the map zooms, so it travels on the buffer rather than as a prop. - _trails.zoom = camera.zoom; - - sim.step(camera, size); - _repaint.mark(); - _steps++; - _stalledOn = null; - } - - void _stall(String reason) => _stalledOn = reason; - - @override - Widget build(BuildContext context) { - // Its own layer: this repaints every frame the ticker runs, and it sits in - // the map's overlay slot alongside chrome that changes only on interaction. - // Without the boundary each streak frame would dirty that whole layer. - return RepaintBoundary( - child: CustomPaint( - painter: _WindParticlePainter( - // Null for the whole gesture: the painter draws nothing, so the - // particles are simply gone until the map settles. - sim: _interacting ? null : _sim, - trails: _trails, - repaint: _repaint, - ), - size: Size.infinite, - ), - ); - } - - @override - void dispose() { - _watchdog?.cancel(); - WidgetsBinding.instance.removeObserver(this); - _visibleTab?.removeListener(_syncVisibility); - widget.layer.interacting.removeListener(_onInteracting); - widget.layer.field.removeListener(_onField); - _ticker.dispose(); - _repaint.dispose(); - _trails.dispose(); - super.dispose(); - } -} - -/// The streaks, as the last [historyFrames] frames of particle positions. -/// -/// This used to be an accumulation buffer: each frame the previous picture was -/// drawn back at a fade opacity, the new dots stamped over it, and the result -/// rasterised with `toImageSync` — one synchronous GPU round-trip on the UI -/// thread, every frame. Elegant, and the source of the bug that would not die: -/// when that readback returns a stale texture, the overlay shows a frozen -/// picture *while everything behind it keeps working perfectly*. The -/// simulation steps, the ticker fires, the painter is marked dirty — so no -/// amount of watching for a stalled animation can detect it, and no pan, zoom -/// or restart clears it. Rotating the map hammers that path hardest, because -/// every frame of a rotation discards the buffer and demands a fresh readback. -/// -/// So there is no readback any more, and no texture that can go stale. A -/// streak is simply the last N frames of dots drawn together with an alpha -/// ramp — the same picture the fade produced, built from data the CPU owns -/// outright. The trade is a fixed number of extra `drawRawPoints` calls per -/// frame instead of one composite, against a failure mode that cannot happen. -class _TrailBuffer { - /// How many frames of history make a streak. The old exponential fade - /// (~0.95 a frame) stayed visible for roughly this long before it sank into - /// the background, so the streaks read about the same length. - static const int historyFrames = 14; - static const int _speedBuckets = 16; - static const int _bucketCapacity = 6400 * 2; - - static final List _headColors = List.generate(_speedBuckets, (i) { - final t = (i + 0.5) / _speedBuckets; - return Color.fromRGBO(255, 255, 255, 0.35 + 0.55 * t); - }, growable: false); - - static final List> _tailColors = List.generate( - historyFrames + 1, - (tail) => List.generate(tail, (index) { - final age = index + 1; - final t = 1.0 - (age - 1) / tail; - return Color.fromRGBO(255, 255, 255, 0.5 * t * t); - }, growable: false), - growable: false, - ); - - final Paint _paint = Paint()..strokeCap = StrokeCap.round; - - /// Speed buckets for the newest frame, reused across frames. Each bucket is - /// an interleaved `x,y` [Float32List] for [Canvas.drawRawPoints], so the - /// stamp path allocates nothing per frame — `drawPoints` would need a fresh - /// [Offset] per visible particle (up to 6400 of them) for the collector to - /// chase on the hottest path in the app. - final List _buckets = List.generate( - _speedBuckets, - (_) => Float32List(_bucketCapacity), - growable: false, - ); - - /// Live point count per bucket this frame. - /// - /// 16 bits, not 8: the whole population (6400) can land in one speed bucket - /// under strong wind, and an 8-bit counter wraps at 255 — the bucket then - /// draws the wrong point count (or none at all, when the count wraps to 0), - /// which reads as particles vanishing. - final Uint16List _counts = Uint16List(_speedBuckets); - - /// The tail: a ring of past frames, each a flat `x,y` list of the positions - /// that were visible then. Positions, not particles — a particle that - /// respawns must not drag its old streak across the screen to the new place. - final List _history = List.generate( - historyFrames, - (_) => Float32List(_bucketCapacity), - growable: false, - ); - final Uint16List _historyCounts = Uint16List(historyFrames); - int _head = 0; - int _filled = 0; - - /// Live camera zoom, written by the ticker: the painter needs it at paint - /// time and it is not known at build time — the widget does not rebuild when - /// the map zooms. - double zoom = - 3; // the wind layer's own floor, until the ticker says otherwise - - /// How many frames of tail to actually draw, set from the device tier. - int tailFrames = historyFrames; - - /// Drops the tail. Called on any camera change: a streak drawn for one view - /// is wrong for the next, and smearing it across the pan is worse than - /// starting over. - void clear() { - _head = 0; - _filled = 0; - _historyCounts.fillRange(0, historyFrames, 0); - } - - void dispose() => clear(); - - /// Draws the tail then the head, oldest first so newer dots sit on top. - void paint(Canvas canvas, Iterable particles) { - final paint = _paint..strokeWidth = pointSizeFor(zoom); - - // The tail, uniform white, fading with age. Oldest first. - final tail = math.min(_filled, tailFrames); - for (var age = tail; age >= 1; age--) { - final slot = (_head - age) % historyFrames; - final index = slot < 0 ? slot + historyFrames : slot; - final count = _historyCounts[index]; - if (count == 0) continue; - // The same linear alpha ramp as before, cached because it depends only - // on the bounded tail length rather than any frame data. - paint.color = _tailColors[tail][age - 1]; - canvas.drawRawPoints( - ui.PointMode.points, - Float32List.sublistView(_history[index], 0, count * 2), - paint, - ); - } - - _stampAndRecordHead(canvas, particles, paint); - } - - /// The newest frame: bucketed by speed for the draw (the head of each streak - /// is brighter where the wind is stronger) and recorded flat into the - /// history ring for the next frames' tails — one pass over the population, - /// where stamping and recording separately walked it twice a frame. - /// - /// The web gives every point its own alpha from a fragment shader; a - /// [Canvas] carries one colour per call, so the field is bucketed and drawn a - /// bucket at a time. Sixteen steps across an alpha range of 0.55 is a third - /// of a level apart in 8-bit terms — below what rounding does to it anyway. - void _stampAndRecordHead( - Canvas canvas, - Iterable particles, - Paint paint, - ) { - final points = _buckets; - final counts = _counts; - counts.fillRange(0, _speedBuckets, 0); - final slot = _history[_head]; - var recorded = 0; - // Reciprocal once; a divide per particle per frame is the kind of cost - // this loop is too hot to carry. - const invScale = 1 / kWindSpeedScale; - for (final p in particles) { - if (!p.visible) continue; - // [speed] is a square root and therefore non-negative. Saturating with - // one branch is equivalent to clamp + min without two generic helpers - // for every visible particle. - final scaledSpeed = p.speed * invScale; - final bi = scaledSpeed >= 1 || scaledSpeed.isNaN - ? _speedBuckets - 1 - : (scaledSpeed * _speedBuckets).floor(); - final i = counts[bi]; - if (i * 2 + 1 < _bucketCapacity) { - counts[bi] = i + 1; - final b = points[bi]; - b[i * 2] = p.sx; - b[i * 2 + 1] = p.sy; - } - if (recorded * 2 + 1 < _bucketCapacity) { - slot[recorded * 2] = p.sx; - slot[recorded * 2 + 1] = p.sy; - recorded++; - } - } - _historyCounts[_head] = recorded; - _head = (_head + 1) % historyFrames; - if (_filled < historyFrames) _filled++; - - for (var i = 0; i < _speedBuckets; i++) { - final count = counts[i]; - if (count == 0) continue; - // The bucket's midpoint on the web ramp, prebuilt once rather than - // allocating sixteen colours on every frame. - paint.color = _headColors[i]; - canvas.drawRawPoints( - ui.PointMode.points, - Float32List.sublistView(points[i], 0, count * 2), - paint, - ); - } - } -} - -/// Draws the streaks over the map. -class _WindParticlePainter extends CustomPainter { - _WindParticlePainter({ - required this.sim, - required this.trails, - required Listenable repaint, - }) : super(repaint: repaint); - - final WindParticleSim? sim; - final _TrailBuffer trails; - - @override - void paint(Canvas canvas, Size size) { - final sim = this.sim; - if (sim == null || size.isEmpty) return; - trails.paint(canvas, sim.particles); - } - - @override - bool shouldRepaint(covariant _WindParticlePainter oldDelegate) => - oldDelegate.sim != sim; -} - -/// A [ChangeNotifier] whose only job is to mark the painter dirty — a private -/// subclass so the state can call [ChangeNotifier.notifyListeners] without the -/// "visible for testing" lint. -class _OverlayRepaint extends ChangeNotifier { - void mark() => notifyListeners(); -} diff --git a/lib/features/more/domain/developer_note.dart b/lib/features/more/domain/developer_note.dart index 95c3a232e..3570027fc 100644 --- a/lib/features/more/domain/developer_note.dart +++ b/lib/features/more/domain/developer_note.dart @@ -8,10 +8,18 @@ /// gate scans [AppLocalizations]-routed files; a moving target here would /// churn the generated delegates for nothing. class DeveloperNote { - const DeveloperNote({required this.title, required this.body}); + const DeveloperNote({ + required this.title, + required this.body, + required this.date, + }); final String title; final String body; + + /// When the note was written, shown as a caption beside the title — + /// a note about live incidents reads differently as it ages. + final String date; } /// The note per locale, keyed by `Locale.toString()` (`zh_TW`, `en`, …). @@ -19,113 +27,119 @@ class DeveloperNote { const Map _developerNotes = { 'zh_TW': DeveloperNote( title: '開發者的話', + date: '2026-08-26', body: - '我們注意到 Android 版本的 DPIP 還有不少問題,我們正在調查原因,' - '將盡快修正並發布更新。若有其他問題可以至 Discord 社群回報,' - '我們願意傾聽,但請不要直接至商店負評,直接負評的溝通效率很差,' - '且對我們的打擊很大。', + '錯誤回報的速度,一直比修復的速度快——我們正以最快的步調趕工,' + '懇請大家多多體諒。更新內容與各項處理進度,' + '都能在本頁下方的「更新日誌」與「已回報的錯誤」追蹤。' + '若有余力,也歡迎透過「支援 DPIP」給我們鼓勵與支持。', ), 'zh': DeveloperNote( - title: '開發者的話', + title: '开发者的话', + date: '2026-08-26', body: - '我們注意到 Android 版本的 DPIP 還有不少問題,我們正在調查原因,' - '將盡快修正並發布更新。若有其他問題可以至 Discord 社群回報,' - '我們願意傾聽,但請不要直接至商店負評,直接負評的溝通效率很差,' - '且對我們的打擊很大。', + '错误回报的速度,一直比修复的速度快——我们正以最快的步伐赶工,' + '恳请大家多多体谅。更新内容与各项处理进度,' + '都能在本页下方的“更新日志”与“已回报的错误”追踪。' + '若有余力,也欢迎通过“支援 DPIP”给我们鼓励与支持。', ), 'zh_Hant_HK': DeveloperNote( title: '開發者的話', + date: '2026-08-26', body: - '我們留意到 Android 版本的 DPIP 還有不少問題,我們正在調查原因,' - '會盡快修正並發布更新。如有其他問題可以到 Discord 社群回報,' - '我們願意傾聽,但請不要直接在商店留負評,直接負評的溝通效率很低,' - '對我們的打擊也很大。', + '錯誤回報的速度,一直比修復的速度快——我們正以最快的步伐趕工,' + '懇請大家多多體諒。更新內容與各項處理進度,' + '都能在本頁下方的「更新日誌」與「已回報的錯誤」追蹤。' + '若有餘力,也歡迎透過「支援 DPIP」給我們鼓勵與支持。', ), 'zh_Hans': DeveloperNote( title: '开发者的话', + date: '2026-08-26', body: - '我们注意到 Android 版本的 DPIP 还有不少问题,我们正在调查原因,' - '将尽快修复并发布更新。如有其他问题可以在 Discord 社区反馈,' - '我们愿意倾听,但请不要直接在商店打差评,差评的沟通效率很低,' - '对我们打击也很大。', + '错误回报的速度,一直比修复的速度快——我们正以最快的步伐赶工,' + '恳请大家多多体谅。更新内容与各项处理进度,' + '都能在本页下方的“更新日志”与“已回报的错误”追踪。' + '若有余力,也欢迎通过“支援 DPIP”给我们鼓励与支持。', ), 'yue': DeveloperNote( title: '開發者嘅話', + date: '2026-08-26', body: - '我哋留意到 Android 版嘅 DPIP 仲有唔少問題,我哋而家正在調查原因,' - '會盡快修正同發布更新。如果仲有其他問題,可以去 Discord 社群回報,' - '我哋願意傾聽,但請唔好直接去商店留負評,負評嘅溝通效率好差,' - '對我哋打擊好大。', + '問題回報嘅速度,一直比修復嘅速度快——我哋正以最快嘅步伐趕工,' + '希望大家多多體諒。更新內容同各項處理進度,' + '都可以喺本頁下方嘅「更新日誌」同「已回報嘅錯誤」查看。' + '如果有力,歡迎透過「支援 DPIP」畀我哋鼓勵同支持。', ), 'en': DeveloperNote( title: 'A word from the developers', + date: '2026-08-26', body: - 'We know the Android version of DPIP still has a number of issues. ' - 'We are investigating the causes and will fix them and ship an update ' - 'as soon as possible. For anything else, please report it in our ' - 'Discord community — we are listening — but please do not leave a ' - 'negative review on the store: reviews are a poor channel for ' - 'feedback, and they hurt us a lot.', + 'Bug reports keep arriving faster than we can fix them — the team is ' + 'working flat out, and we ask for your patience. Every fix and its ' + 'progress can be tracked under “Changelog” and “Reported bugs” at ' + 'the bottom of this page. If you are able to, a little support ' + 'through “Support DPIP” goes a long way.', ), 'ja': DeveloperNote( title: '開発者からのお知らせ', + date: '2026-08-26', body: - 'Android 版の DPIP にはまだ多くの問題があることを認識しています。' - '原因を調査中で、できるだけ早く修正しアップデートをリリースします。' - 'その他の問題があれば Discord コミュニティまでご報告ください。' - '拝聴いたしますが、ストアへの低評価だけはご遠慮ください。' - '低評価はフィードバックの伝達効率が悪く、私たちにとって大きな' - '打撃となります。', + 'バグ報告のペースは修正のペースを常に上回っており、' + 'チームは全力で対応を進めています。今しばらくお待ちください。' + '修正内容と進捗は、このページ下部の「更新履歴」と' + '「報告済みのバグ」からご確認いただけます。' + '余裕のある方は「DPIP を支援」から応援していただけると嬉しいです。', ), 'ko': DeveloperNote( title: '개발자의 말', + date: '2026-08-26', body: - 'Android 버전의 DPIP에 아직 적지 않은 문제가 있음을 알고 있습니다. ' - '원인을 조사 중이며, 최대한 빨리 수정하고 업데이트를 출시하겠습니다. ' - '다른 문제가 있으면 Discord 커뮤니티에 알려 주세요. 저희가 귀 ' - '기울여 듣겠습니다. 다만 스토어에 낮은 평점을 남기는 것은 삼가 ' - '주세요. 낮은 평점은 소통 효율이 매우 낮고, 저희에게 큰 타격이 됩니다.', + '버그 리포트가 수정 속도보다 빠르게 쌓이고 있습니다. ' + '팀은 전력을 다해 대응 중이오니 너른 양해 부탁드립니다. ' + '수정 내역과 진행 상황은 이 페이지 하단의 “업데이트 기록”과 ' + '“보고된 버그”에서 확인하실 수 있습니다. ' + '여유가 되신다면 “DPIP 지원”을 통해 응원해 주시면 큰 힘이 됩니다.', ), 'th': DeveloperNote( title: 'ข้อความจากทีมพัฒนา', + date: '2026-08-26', body: - 'เราทราบว่าแอป DPIP เวอร์ชัน Android ยังมีปัญหาอีกหลายจุด ' - 'เรากำลังหาสาเหตุและจะรีบแก้ไขพร้อมปล่อยอัปเดตโดยเร็วที่สุด ' - 'หากมีปัญหาอื่น ๆ แจ้งได้ที่ชุมชน Discord เรายินดีรับฟัง ' - 'แต่ขออย่าโพสต์รีวิวไม่ดีที่หน้าร้านแอป เพราะรีวิวไม่ดีสื่อสารได้ไม่ตรงจุด ' - 'และกระทบเราอย่างหนัก', + 'รายงานบั๊กเข้ามาเร็วกว่าที่เราแก้ไขทัน ' + 'ทีมงานกำลังเร่งดำเนินการเต็มที่ ขอความอดทนจากทุกคนด้วย ' + 'ติดตามความคืบหน้าและเนื้อหาการอัปเดตได้ที่ ' + '"Update log" และ "Reported bugs" ด้านล่างของหน้านี้ ' + 'หากพอมีกำลัง ช่วยสนับสนุนเราผ่าน "Support DPIP"', ), 'vi': DeveloperNote( title: 'Lời từ đội ngũ phát triển', + date: '2026-08-26', body: - 'Chúng tôi biết phiên bản DPIP trên Android vẫn còn khá nhiều vấn đề. ' - 'Chúng tôi đang điều tra nguyên nhân và sẽ sớm khắc phục cùng phát ' - 'hành bản cập nhật. Nếu gặp vấn đề khác, bạn có thể phản ánh tại cộng ' - 'đồng Discord — chúng tôi sẵn sàng lắng nghe — nhưng mong bạn đừng để ' - 'đánh giá tiêu cực trên cửa hàng ứng dụng, vì đánh giá tiêu cực không ' - 'phải kênh trao đổi hiệu quả và ảnh hưởng rất lớn đến chúng tôi.', + 'Tốc độ nhận báo cáo lỗi luôn nhanh hơn tốc độ khắc phục — ' + 'đội ngũ đang làm việc hết công suất, mong mọi người thông cảm. ' + 'Nội dung cập nhật và tiến trình xử lý có thể theo dõi tại ' + '"Nhật ký cập nhật" và "Lỗi đã báo cáo" ở phần dưới trang này. ' + 'Nếu dư sức, hãy ủng hộ chúng tôi qua "Hỗ trợ DPIP".', ), 'id': DeveloperNote( title: 'Kata dari pengembang', + date: '2026-08-26', body: - 'Kami menyadari DPIP versi Android masih memiliki cukup banyak ' - 'masalah. Kami sedang menyelidiki penyebabnya dan akan segera ' - 'memperbaikinya serta merilis pembaruan. Jika ada masalah lain, ' - 'silakan laporkan ke komunitas Discord — kami siap mendengarkan — ' - 'tetapi mohon jangan memberi ulasan buruk di toko aplikasi, karena ' - 'ulasan buruk bukan sarana komunikasi yang efektif dan sangat ' - 'berdampak bagi kami.', + 'Laporan bug selalu masuk lebih cepat daripada perbaikannya — ' + 'tim sedang bekerja sekuat mungkin dan mohon pengertian Anda. ' + 'Setiap perbaikan dan progresnya bisa dipantau di bagian bawah ' + 'halaman ini, pada "Log pembaruan" dan "Bug yang dilaporkan". ' + 'Jika ada kemampuan, dukung kami melalui "Support DPIP".', ), 'fil': DeveloperNote( title: 'Mensahe mula sa mga developer', + date: '2026-08-26', body: - 'Alam namin na may ilan pang problema ang bersyon ng DPIP sa Android. ' - 'Iniimbestigahan namin ang dahilan at aayusin namin ito sa lalong ' - 'madaling panahon, kasabay ng paglabas ng update. Kung may iba pang ' - 'problema, maaari kayong mag-ulat sa Discord community — handa kaming ' - 'makinig — ngunit mangyaring huwag mag-iwan ng negatibong review sa ' - 'app store, dahil hindi epektibong paraan ng pakikipag-ugnayan ang ' - 'negatibong review at malaki ang epekto nito sa amin.', + 'Mas mabilis ang pagdating ng mga bug report kaysa sa pag-aayos ' + 'namin — pinupuno namin ang lahat ng aming makakaya, hiling namin ' + 'ang inyong pang-unawa. Makikita ang bawat ayos at progreso sa ' + 'ilalim ng pahinang ito: sa "Changelog" at "Mga naulat na bug". ' + 'Kung may kakayahan kayo, suportahan kami sa pamamagitan ng ' + '"Support DPIP".', ), }; diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index ce3414226..0bdf16b85 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -857,12 +857,23 @@ class _DeveloperNoteCard extends StatelessWidget { ), const SizedBox(width: AppSpacing.sm), Expanded( - child: Text( - note.title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + note.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + Text( + note.date, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], ), ), ], diff --git a/lib/features/release_highlights/data/release_highlight_repository.dart b/lib/features/release_highlights/data/release_highlight_repository.dart index 4bb596e74..e5eaccf7f 100644 --- a/lib/features/release_highlights/data/release_highlight_repository.dart +++ b/lib/features/release_highlights/data/release_highlight_repository.dart @@ -7,7 +7,7 @@ /// imports with the new version's; nothing else changes. /// /// Content is authored as JSON at `release_highlights//…/cards.json` -/// and compiled to Dart by `tool/json_to_dart_highlights.py`. +/// and compiled to Dart by `tool/gen/release_highlights.py`. library; import 'package:dpip/features/release_highlights/domain/release_highlight.dart'; diff --git a/lib/features/release_highlights/domain/release_highlight.dart b/lib/features/release_highlights/domain/release_highlight.dart index 619db63d7..c8e52236f 100644 --- a/lib/features/release_highlights/domain/release_highlight.dart +++ b/lib/features/release_highlights/domain/release_highlight.dart @@ -8,7 +8,7 @@ /// content package, keyed by locale so every language reads its own copy. /// /// A new version is authored as JSON under `release_highlights//…`, -/// converted to Dart by `tool/json_to_dart_highlights.py`, and the app's +/// converted to Dart by `tool/gen/release_highlights.py`, and the app's /// repository imports the new version's files. Older versions stay in the /// package as the archive; unimported, they are never compiled into a build. library; diff --git a/lib/features/settings/presentation/pages/developer_page.dart b/lib/features/settings/presentation/pages/developer_page.dart index 63506d7d2..26eda2783 100644 --- a/lib/features/settings/presentation/pages/developer_page.dart +++ b/lib/features/settings/presentation/pages/developer_page.dart @@ -36,6 +36,9 @@ typedef _Field = ({String label, String? value}); /// Shared by the row, the dialog title, and its confirm button. const String _clearCacheTitle = 'Clear cache'; +/// Shared by the row, the dialog title, and its confirm button. +const String _clearTrackTitle = 'Clear location track'; + class DeveloperPage extends StatefulWidget { const DeveloperPage({super.key}); @@ -50,6 +53,7 @@ class _DeveloperPageState extends State { StorageScan? _storage; List? _tables; bool _clearing = false; + bool _clearingTrack = false; /// Version-row taps toward the experimental unlock. Deliberately not /// persisted — a fresh app start re-arms the easter egg. @@ -161,6 +165,56 @@ class _DeveloperPageState extends State { ..showSnackBar(const SnackBar(content: Text('Cache cleared'))); } + /// Empties the on-device movement history. + /// + /// Confirmed first, and worded as permanent because it is: the track is a + /// record of where this device has been, and unlike the cache above nothing + /// downloads it again. What is deleted is gone. + /// + /// The delete itself is native — see [BackgroundLocationService.clearTrack] + /// for why Dart must not unlink the file. + Future _confirmClearTrack() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text(_clearTrackTitle), + content: const Text( + 'The recorded movement history on this device will be deleted and ' + 'the space returned. Recording continues; only what has already ' + 'been recorded is removed. This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text(_clearTrackTitle), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + final backgroundLocation = context.read(); + setState(() => _clearingTrack = true); + try { + await backgroundLocation.clearTrack(); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'dev: clear location track'); + } + if (!mounted) return; + setState(() => _clearingTrack = false); + // Re-read so the Track fixes row above shows the emptied store rather than + // the count the button was pressed to get rid of. + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('Location track cleared'))); + } + /// Labels omitted from the clipboard dump (still shown on screen). /// Labels that get their own copy button — long push tokens are the one /// case worth lifting out of a diagnostics screenshot on their own; every @@ -291,6 +345,25 @@ class _DeveloperPageState extends State { ), onTap: _reportNow, ), + ListTile( + leading: Icon( + Icons.route_outlined, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + _clearTrackTitle, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + subtitle: const Text( + 'Deletes the recorded movement history on this device', + ), + trailing: _clearingTrack + ? const InlineLoading(size: 18) + : null, + onTap: _clearingTrack ? null : _confirmClearTrack, + ), ListTile( leading: Icon( Icons.delete_sweep_outlined, diff --git a/lib/features/status/presentation/pages/server_status_page.dart b/lib/features/status/presentation/pages/server_status_page.dart index 739229856..39f37cb8f 100644 --- a/lib/features/status/presentation/pages/server_status_page.dart +++ b/lib/features/status/presentation/pages/server_status_page.dart @@ -817,7 +817,7 @@ class _StatusGrid extends StatelessWidget { child: _metricCard( context, title: l10n.serverStatusErrorRate, - value: '${status.errorRate.value.toStringAsFixed(2)}%', + value: '${_compactPercent(status.errorRate.value)}%', subtitle: _maybeInstance(status.errorRate.instance), color: _threeTone(context, status.errorRate.value, 0.1, 0.3), ), @@ -1127,6 +1127,27 @@ extension on BuildContext { ColorScheme get colorScheme => theme.colorScheme; } +/// 最多三位數字的緊湊百分比——`0.02`、`30.2`、`124`。 +/// +/// 小數位讓給有效位數:數字越小保留越多小數,越大越早收整數, +/// 但任何狀態下渲染出來的數字符號都不超過三個,卡片寬度就不會被 +/// `100.00%` 這類長度擠壞。 +String _compactPercent(num value) { + if (value == 0) return '0'; + var text = ''; + for (var decimals = 2; decimals >= 0; decimals--) { + text = value.toStringAsFixed(decimals); + final digits = text.replaceAll(RegExp(r'[^0-9]'), ''); + // 前導零不算位數(`0.02` 只有「2」一位)。 + if (digits.replaceFirst(RegExp(r'^0+(?=.)'), '').length <= 3) break; + } + if (text.contains('.')) { + text = text.replaceFirst(RegExp(r'0+$'), ''); + text = text.replaceFirst(RegExp(r'\.$'), ''); + } + return text; +} + Color _threeTone(BuildContext context, num value, double warn, double bad) { final colors = context.colorScheme; if (value >= bad) return colors.error; diff --git a/lib/features/weather/data/frame_tile_repository.dart b/lib/features/weather/data/frame_tile_repository.dart index ca4a89759..7099bfe90 100644 --- a/lib/features/weather/data/frame_tile_repository.dart +++ b/lib/features/weather/data/frame_tile_repository.dart @@ -249,7 +249,12 @@ final class FrameTileRepositoryImpl extends FrameTileRepository SatelliteRepository, QpesumsRepository, WindForecastRepository { - FrameTileRepositoryImpl(this._api, super.warmer, {this.maxZoom = 11}); + FrameTileRepositoryImpl( + this._api, + super.warmer, { + this.maxZoom = 11, + this.minZoom = 0, + }); final FrameTileApi _api; @@ -265,9 +270,15 @@ final class FrameTileRepositoryImpl extends FrameTileRepository @override final int maxZoom; + /// The published floor (see [RasterFrameSource.sourceMinZoom]). + final int minZoom; + @override int get sourceMaxZoom => maxZoom; + @override + int get sourceMinZoom => minZoom; + @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 19e7cdcdb..b3a3f34f0 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -33,26 +33,30 @@ 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, + // The origin publishes z3–12. At three Taiwan locations and three + // content-rich frames, z11 overzoom versus native z12 stayed visually + // near-identical (median SSIM 0.963, MAE 1.42/255), so stop at z11. + maxZoom: 11, + minZoom: 3, ), ), Provider.value( value: FrameTileRepositoryImpl( FrameTileApi(deps.apiClient, 'qpesums'), deps.mapTileWarmer(), - // Same shape as radar: publishes z3–12, information ends ~z7. - maxZoom: 8, + // Also publishes z3–12; z11 overzoom versus z12 measured median SSIM + // 0.980 and MAE 0.89/255, so z12 is not worth another request level. + maxZoom: 11, + minZoom: 3, ), ), 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, + // Every satellite product and colour style keeps the complete + // published z0–11 pyramid. + maxZoom: 11, ), ), // One repository per channel the satellite layer picker offers — each needs @@ -64,22 +68,22 @@ List weatherProviders(SharedDeps deps) { channel: FrameTileRepositoryImpl( FrameTileApi(deps.apiClient, 'satellite', channel: channel.key), deps.mapTileWarmer(), - maxZoom: 8, + maxZoom: 11, ), }, ), - // 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 — 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. + // 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. Both + // model endpoints publish z0–11. At three Taiwan locations across three + // cycle positions, z6 overzoom versus native z11 measured SSIM >= 0.986 at + // p10 for both 0.25° models; z5 still changed visibly. Stop at useful z6. Provider>.value( value: { for (final model in WindForecastModel.values) model: FrameTileRepositoryImpl( FrameTileApi(deps.apiClient, 'wind', model: model.key), deps.mapTileWarmer(), - maxZoom: 7, + maxZoom: 6, ), }, ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 09ae5a5e6..c99e90d94 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2668,6 +2668,10 @@ "releaseHighlightsEmpty": "Nothing here yet.", "releaseHighlightsSeeNotes": "Full release notes", "moreVersionNotesEmpty": "No changelog for this build", + "reportNotFound": "That earthquake report is no longer available", + "@reportNotFound": { + "description": "Shown when a report opened from a notification or a deep link no longer exists (HTTP 404), just before the user is returned to the report list." + }, "moreVersionSnapshot": "Snapshot", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", "@meshtasticScanning": { @@ -3950,5 +3954,9 @@ }, "@bugTrackerSortMostDiscussed": { "description": "Sort chip: threads with the most replies first" + }, + "bugTrackerStaff": "Staff", + "@bugTrackerStaff": { + "description": "Badge beside triage-team names on bug threads" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 1ec030482..2a1746f16 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "Mas malalim", "releaseHighlightsEmpty": "Wala pang laman.", "moreVersionNotesEmpty": "Walang changelog para sa build na ito", + "reportNotFound": "Hindi mahanap ang ulat ng lindol na ito", "moreVersionSnapshot": "Bersyon ng pagsubok", "mapLayerSatelliteTransparentNoData": "Walang data (lupa) = transparent", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "Hindi maipakita ang nilalaman na ito — tingnan sa Discord", "bugTrackerJoinDiscussion": "Makilahok sa talakayan sa Discord", "bugTrackerSortLast": "Pinakabagong aktibidad", - "bugTrackerSortMostDiscussed": "Pinakamaraming talakayan" + "bugTrackerSortMostDiscussed": "Pinakamaraming talakayan", + "bugTrackerStaff": "Kawani" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index abc1af994..689b14fa2 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "Mendalam", "releaseHighlightsEmpty": "Belum ada konten.", "moreVersionNotesEmpty": "Tidak ada changelog untuk build ini", + "reportNotFound": "Laporan gempa ini tidak ditemukan", "moreVersionSnapshot": "Versi uji", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "Konten ini tidak dapat ditampilkan — lihat di Discord", "bugTrackerJoinDiscussion": "Ikuti diskusi di Discord", "bugTrackerSortLast": "Aktivitas terbaru", - "bugTrackerSortMostDiscussed": "Paling banyak dibahas" + "bugTrackerSortMostDiscussed": "Paling banyak dibahas", + "bugTrackerStaff": "Staf" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 00f89f532..2f939c045 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "技術詳細", "releaseHighlightsEmpty": "まだコンテンツがありません。", "moreVersionNotesEmpty": "このビルドの更新履歴が見つかりません", + "reportNotFound": "この地震報告は見つかりませんでした", "moreVersionSnapshot": "テスト版", "mapLayerSatelliteTransparentNoData": "データなし(陸上) = 透明", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "この内容は表示できません — Discord でご確認ください", "bugTrackerJoinDiscussion": "Discord で議論に参加する", "bugTrackerSortLast": "最新の返信", - "bugTrackerSortMostDiscussed": "返信が多い順" + "bugTrackerSortMostDiscussed": "返信が多い順", + "bugTrackerStaff": "スタッフ" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index cbd563a66..3a6f36861 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "기술 세부", "releaseHighlightsEmpty": "아직 내용이 없습니다.", "moreVersionNotesEmpty": "이 빌드의 업데이트 내역을 찾을 수 없습니다", + "reportNotFound": "해당 지진 보고서를 찾을 수 없습니다", "moreVersionSnapshot": "테스트 버전", "mapLayerSatelliteTransparentNoData": "데이터 없음(육지) = 투명", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "이 내용을 표시할 수 없습니다 — Discord에서 확인하세요", "bugTrackerJoinDiscussion": "Discord에서 논의에 참여하기", "bugTrackerSortLast": "최근 활동", - "bugTrackerSortMostDiscussed": "답글 많은 순" + "bugTrackerSortMostDiscussed": "답글 많은 순", + "bugTrackerStaff": "스태프" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index b13f92e37..4cabac60c 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "เจาะลึก", "releaseHighlightsEmpty": "ยังไม่มีเนื้อหา", "moreVersionNotesEmpty": "ไม่พบประวัติการอัปเดตสำหรับบิลด์นี้", + "reportNotFound": "ไม่พบรายงานแผ่นดินไหวนี้", "moreVersionSnapshot": "เวอร์ชันทดสอบ", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "ไม่สามารถแสดงเนื้อหานี้ได้ — ดูได้ที่ Discord", "bugTrackerJoinDiscussion": "ร่วมพูดคุยที่ Discord", "bugTrackerSortLast": "ล่าสุด", - "bugTrackerSortMostDiscussed": "พูดคุยมากที่สุด" + "bugTrackerSortMostDiscussed": "พูดคุยมากที่สุด", + "bugTrackerStaff": "ทีมงาน" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 26f9f5402..585fd637b 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "Đi sâu", "releaseHighlightsEmpty": "Chưa có nội dung.", "moreVersionNotesEmpty": "Không tìm thấy nhật ký cập nhật cho bản này", + "reportNotFound": "Không tìm thấy báo cáo động đất này", "moreVersionSnapshot": "Bản thử nghiệm", "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "Không thể hiển thị nội dung này — xem trên Discord", "bugTrackerJoinDiscussion": "Tham gia thảo luận trên Discord", "bugTrackerSortLast": "Hoạt động mới nhất", - "bugTrackerSortMostDiscussed": "Nhiều thảo luận nhất" + "bugTrackerSortMostDiscussed": "Nhiều thảo luận nhất", + "bugTrackerStaff": "Nhân sự" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index ceca4e183..23614e1a5 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "深入技術", "releaseHighlightsEmpty": "而家冇內容。", "moreVersionNotesEmpty": "找唔到而家版本嘅更新日誌", + "reportNotFound": "搵唔到呢份地震報告", "moreVersionSnapshot": "測試版", "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "無法顯示呢個內容,請去 Discord 查看", "bugTrackerJoinDiscussion": "去 Discord 一齊傾", "bugTrackerSortLast": "最後傾偈", - "bugTrackerSortMostDiscussed": "最多討論" + "bugTrackerSortMostDiscussed": "最多討論", + "bugTrackerStaff": "工作人員" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 77032a60f..d8cfad62d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "深入技術", "releaseHighlightsEmpty": "目前沒有內容。", "moreVersionNotesEmpty": "找不到目前版本的更新日誌", + "reportNotFound": "找不到這份地震報告", "moreVersionSnapshot": "測試版", "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", "@meshtasticScanning": { @@ -1972,5 +1973,6 @@ "bugTrackerCannotDisplay": "无法显示此内容,请在 Discord 上查看", "bugTrackerJoinDiscussion": "至 Discord 参与讨论", "bugTrackerSortLast": "最后讨论", - "bugTrackerSortMostDiscussed": "最多讨论" + "bugTrackerSortMostDiscussed": "最多讨论", + "bugTrackerStaff": "工作人员" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 3b1b1a3a2..82b149b76 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "深入技术", "releaseHighlightsEmpty": "目前没有内容。", "moreVersionNotesEmpty": "找不到当前版本的更新日志", + "reportNotFound": "找不到这份地震报告", "moreVersionSnapshot": "測試版", "mapLayerSatelliteTransparentNoData": "无资料(陆地) = 透明", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "无法显示此内容,请在 Discord 上查看", "bugTrackerJoinDiscussion": "至 Discord 参与讨论", "bugTrackerSortLast": "最后讨论", - "bugTrackerSortMostDiscussed": "最多讨论" + "bugTrackerSortMostDiscussed": "最多讨论", + "bugTrackerStaff": "工作人员" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 4a3e20ad1..397163df6 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "深入技術", "releaseHighlightsEmpty": "目前沒有內容。", "moreVersionNotesEmpty": "找不到目前版本的更新日誌", + "reportNotFound": "搵唔到呢份地震報告", "moreVersionSnapshot": "測試版", "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "無法顯示此內容,請在 Discord 上查看", "bugTrackerJoinDiscussion": "至 Discord 參與討論", "bugTrackerSortLast": "最後討論", - "bugTrackerSortMostDiscussed": "最多討論" + "bugTrackerSortMostDiscussed": "最多討論", + "bugTrackerStaff": "工作人員" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 5dfbfb93a..29c787c8c 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1023,6 +1023,7 @@ "releaseHighlightsTabAdvanced": "深入技術", "releaseHighlightsEmpty": "目前沒有內容。", "moreVersionNotesEmpty": "找不到目前版本的更新日誌", + "reportNotFound": "找不到這份地震報告", "moreVersionSnapshot": "測試版", "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", "@meshtasticScanning": { @@ -1980,5 +1981,6 @@ "bugTrackerCannotDisplay": "無法顯示此內容,請在 Discord 上查看", "bugTrackerJoinDiscussion": "至 Discord 參與討論", "bugTrackerSortLast": "最後討論", - "bugTrackerSortMostDiscussed": "最多討論" + "bugTrackerSortMostDiscussed": "最多討論", + "bugTrackerStaff": "工作人員" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 6181f6206..3d3cf2cbb 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -4151,6 +4151,12 @@ abstract class AppLocalizations { /// **'No changelog for this build'** String get moreVersionNotesEmpty; + /// Shown when a report opened from a notification or a deep link no longer exists (HTTP 404), just before the user is returned to the report list. + /// + /// In en, this message translates to: + /// **'That earthquake report is no longer available'** + String get reportNotFound; + /// No description provided for @moreVersionSnapshot. /// /// In en, this message translates to: @@ -6262,6 +6268,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Most discussed'** String get bugTrackerSortMostDiscussed; + + /// Badge beside triage-team names on bug threads + /// + /// In en, this message translates to: + /// **'Staff'** + String get bugTrackerStaff; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index c5c74f096..074c99832 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -2179,6 +2179,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get moreVersionNotesEmpty => 'No changelog for this build'; + @override + String get reportNotFound => 'That earthquake report is no longer available'; + @override String get moreVersionSnapshot => 'Snapshot'; @@ -3294,4 +3297,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'Most discussed'; + + @override + String get bugTrackerStaff => 'Staff'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 75859137a..580efa4bb 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -2193,6 +2193,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get moreVersionNotesEmpty => 'Walang changelog para sa build na ito'; + @override + String get reportNotFound => 'Hindi mahanap ang ulat ng lindol na ito'; + @override String get moreVersionSnapshot => 'Bersyon ng pagsubok'; @@ -3312,4 +3315,7 @@ class AppLocalizationsFil extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'Pinakamaraming talakayan'; + + @override + String get bugTrackerStaff => 'Kawani'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 79b755199..0cb43d608 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -2185,6 +2185,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get moreVersionNotesEmpty => 'Tidak ada changelog untuk build ini'; + @override + String get reportNotFound => 'Laporan gempa ini tidak ditemukan'; + @override String get moreVersionSnapshot => 'Versi uji'; @@ -3305,4 +3308,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'Paling banyak dibahas'; + + @override + String get bugTrackerStaff => 'Staf'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 2f9c4701c..f7f7124dd 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -2142,6 +2142,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get moreVersionNotesEmpty => 'このビルドの更新履歴が見つかりません'; + @override + String get reportNotFound => 'この地震報告は見つかりませんでした'; + @override String get moreVersionSnapshot => 'テスト版'; @@ -3233,4 +3236,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => '返信が多い順'; + + @override + String get bugTrackerStaff => 'スタッフ'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 840341bb0..1ed48100d 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -2142,6 +2142,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get moreVersionNotesEmpty => '이 빌드의 업데이트 내역을 찾을 수 없습니다'; + @override + String get reportNotFound => '해당 지진 보고서를 찾을 수 없습니다'; + @override String get moreVersionSnapshot => '테스트 버전'; @@ -3233,4 +3236,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => '답글 많은 순'; + + @override + String get bugTrackerStaff => '스태프'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index d1a225ae3..48c1bb12f 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -2174,6 +2174,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get moreVersionNotesEmpty => 'ไม่พบประวัติการอัปเดตสำหรับบิลด์นี้'; + @override + String get reportNotFound => 'ไม่พบรายงานแผ่นดินไหวนี้'; + @override String get moreVersionSnapshot => 'เวอร์ชันทดสอบ'; @@ -3287,4 +3290,7 @@ class AppLocalizationsTh extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'พูดคุยมากที่สุด'; + + @override + String get bugTrackerStaff => 'ทีมงาน'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index f22be1d17..9a1cb1b7a 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -2180,6 +2180,9 @@ class AppLocalizationsVi extends AppLocalizations { String get moreVersionNotesEmpty => 'Không tìm thấy nhật ký cập nhật cho bản này'; + @override + String get reportNotFound => 'Không tìm thấy báo cáo động đất này'; + @override String get moreVersionSnapshot => 'Bản thử nghiệm'; @@ -3295,4 +3298,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'Nhiều thảo luận nhất'; + + @override + String get bugTrackerStaff => 'Nhân sự'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index fc826496a..df9333e1a 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -2131,6 +2131,9 @@ class AppLocalizationsYue extends AppLocalizations { @override String get moreVersionNotesEmpty => '找唔到而家版本嘅更新日誌'; + @override + String get reportNotFound => '搵唔到呢份地震報告'; + @override String get moreVersionSnapshot => '測試版'; @@ -3216,4 +3219,7 @@ class AppLocalizationsYue extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => '最多討論'; + + @override + String get bugTrackerStaff => '工作人員'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index eac3d6ba7..1d6e62400 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -2131,6 +2131,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get moreVersionNotesEmpty => '找不到目前版本的更新日誌'; + @override + String get reportNotFound => '找不到這份地震報告'; + @override String get moreVersionSnapshot => '測試版'; @@ -3216,6 +3219,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => '最多讨论'; + + @override + String get bugTrackerStaff => '工作人员'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -5344,6 +5350,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get moreVersionNotesEmpty => '找不到当前版本的更新日志'; + @override + String get reportNotFound => '找不到这份地震报告'; + @override String get moreVersionSnapshot => '測試版'; @@ -6429,6 +6438,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get bugTrackerSortMostDiscussed => '最多讨论'; + + @override + String get bugTrackerStaff => '工作人员'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -8557,6 +8569,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get moreVersionNotesEmpty => '找不到目前版本的更新日誌'; + @override + String get reportNotFound => '搵唔到呢份地震報告'; + @override String get moreVersionSnapshot => '測試版'; @@ -9642,6 +9657,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get bugTrackerSortMostDiscussed => '最多討論'; + + @override + String get bugTrackerStaff => '工作人員'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -11770,6 +11788,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get moreVersionNotesEmpty => '找不到目前版本的更新日誌'; + @override + String get reportNotFound => '找不到這份地震報告'; + @override String get moreVersionSnapshot => '測試版'; @@ -12855,4 +12876,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get bugTrackerSortMostDiscussed => '最多討論'; + + @override + String get bugTrackerStaff => '工作人員'; } diff --git a/lib/shared/map/base_map.dart b/lib/shared/map/base_map.dart index b2ca58cf8..1d62038e6 100644 --- a/lib/shared/map/base_map.dart +++ b/lib/shared/map/base_map.dart @@ -94,7 +94,10 @@ class BaseMap extends StatefulWidget { /// Alias for call sites / framing that still reference [BaseMap.minZoom]. static const double minZoom = defaultMinZoom; - static const double maxZoom = 11; + /// Deep-zoom ceiling for most surfaces. Was 11, which users kept hitting + /// as "the map goes blurry and then just stops" — vector basemap re-renders + /// crisply far past this, and raster sources now carry their own caps. + static const double maxZoom = 16; /// Called with the controller once the map is ready — add overlay layers here. final void Function(MapLibreMapController controller)? onMapCreated; diff --git a/lib/shared/map/basemap_overlay_sync.dart b/lib/shared/map/basemap_overlay_sync.dart index 59369e700..57b9d6bba 100644 --- a/lib/shared/map/basemap_overlay_sync.dart +++ b/lib/shared/map/basemap_overlay_sync.dart @@ -16,7 +16,7 @@ const RasterDemSourceProperties terrainSourceProps = RasterDemSourceProperties( tiles: [terrainOriginTileUrl], bounds: [110.0, 10.0, 132.0, 35.0], minzoom: 0, - maxzoom: 12, + maxzoom: terrainSourceMaxZoom, tileSize: 512, encoding: 'mapbox', ); diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index d9d9aa550..817a7177a 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -176,7 +176,7 @@ abstract interface class MapLayer { /// the live camera itself every frame — re-keying one of those destroys its /// [State] on every pan, zoom and tap, which for an animation means the /// ticker, the simulation and the accumulated buffer are all rebuilt each - /// time (see `WindParticleOverlay`). + /// time. bool get overlayFollowsCamera => true; /// Camera settled after pan/zoom — sheet layers may prefetch viewport tiles. diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index b79c119a5..2a4dd9bc9 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -802,7 +802,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { north: bounds.northeast.latitude, east: bounds.northeast.longitude, zoom: zoom, - maxZoom: 12, + maxZoom: basemapSourceMaxZoom.toInt(), logLabel: 'basemap', workingSet: 'basemap', immediate: true, @@ -831,7 +831,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { north: bounds.northeast.latitude, east: bounds.northeast.longitude, zoom: zoom, - maxZoom: 12, + maxZoom: terrainSourceMaxZoom.toInt(), logLabel: 'terrain', workingSet: 'terrain', immediate: true, diff --git a/lib/shared/map/map_style.dart b/lib/shared/map/map_style.dart index cff4fca8b..2bc445eb0 100644 --- a/lib/shared/map/map_style.dart +++ b/lib/shared/map/map_style.dart @@ -12,6 +12,11 @@ import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/shared/map/town_label_points.g.dart'; import 'package:dpip/core/network/api_paths.dart'; +/// Highest source levels that add useful visual information. The map's camera +/// may continue past these; MapLibre then overzooms the final native tile. +const double basemapSourceMaxZoom = 9; +const double terrainSourceMaxZoom = 11; + /// One brightness's cartographic hex colours — MapLibre paint strings only. /// /// Do not invent map hexes at call sites; resolve via [MapColors.of]. @@ -272,7 +277,7 @@ String exptechVectorStyle( final terrain = terrainTileUrl == null ? '' : ''' - ,"$terrainSourceId": { "type": "raster-dem", "tiles": ["$terrainTileUrl"], "encoding": "mapbox", "tileSize": 512, "minzoom": 0, "maxzoom": 12, "bounds": [110, 10, 132, 35] }'''; + ,"$terrainSourceId": { "type": "raster-dem", "tiles": ["$terrainTileUrl"], "encoding": "mapbox", "tileSize": 512, "minzoom": 0, "maxzoom": $terrainSourceMaxZoom, "bounds": [110, 10, 132, 35] }'''; final hillshade = terrainTileUrl == null ? '' : ''' @@ -285,7 +290,7 @@ String exptechVectorStyle( "version": 8, "glyphs": "$glyphsUrl", "sources": { - "exptech": { "type": "vector", "tiles": ["$basemapTileUrl"], "maxzoom": 12 }, + "exptech": { "type": "vector", "tiles": ["$basemapTileUrl"], "minzoom": 0, "maxzoom": $basemapSourceMaxZoom }, "$townLabelSourceId": { "type": "geojson", "data": $townLabelData }$terrain }, "layers": [ diff --git a/lib/shared/map/raster_frame_source.dart b/lib/shared/map/raster_frame_source.dart index c61283b5c..527d4208f 100644 --- a/lib/shared/map/raster_frame_source.dart +++ b/lib/shared/map/raster_frame_source.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/error/result.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; /// How much of one frame's current viewport is already in MapLibre's L1. /// @@ -20,18 +21,18 @@ abstract interface class RasterFrameSource { /// Available frame ids, newest first; `Ok([])` when none. Future>> frames(); - /// Highest zoom this overlay's tiles genuinely exist for. + /// Highest native zoom worth requesting for this overlay. /// - /// 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. + /// Live probes establish the published pyramid, then pixel comparisons set a + /// possibly lower useful-detail ceiling. Every MapLibre mount passes this as + /// source `maxzoom`, so the renderer overzooms the last useful level instead + /// of requesting visually redundant tiles (or placeholders past the pyramid). int get sourceMaxZoom; + /// Measured the same way: radar / QPESUMS start at z3; satellite and wind at + /// z0. Every MapLibre mount passes this as the source `minzoom`. + int get sourceMinZoom; + /// XYZ raster tile URL **template** for [frame] (contains `{z}/{x}/{y}`). String tileUrl(String frame); @@ -95,3 +96,20 @@ abstract interface class RasterFrameSource { /// memory (bytes stay in the app's store). For a layer switch / teardown. Future releaseTiles(); } + +/// The one legal way to turn a [RasterFrameSource] frame into a MapLibre tile +/// source. +/// +/// A frame is mounted in three places — the main timeline, the home radar +/// backdrop, and the typhoon weather underlay. Keeping the zoom contract here +/// prevents a secondary surface from silently falling back to MapLibre's +/// raster default (z0–22) and issuing requests outside the published pyramid. +RasterSourceProperties rasterFrameSourceProperties( + RasterFrameSource source, + String frame, +) => RasterSourceProperties( + tiles: [source.tileUrl(frame)], + tileSize: 256, + minzoom: source.sourceMinZoom.toDouble(), + maxzoom: source.sourceMaxZoom.toDouble(), +); diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index da176182b..f6ec5369f 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -1335,15 +1335,10 @@ abstract class RasterTimelineLayer implements MapLayer { await _ensureSeam(controller); await controller.addSource( _sourceId(id), - 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(), - ), + // Past the declared top MapLibre overzooms instead of requesting the + // empty placeholder. The shared factory also protects the home and + // typhoon mounts from drifting back to the raster z0–22 default. + rasterFrameSourceProperties(source, id), ); await controller.addRasterLayer( _sourceId(id), diff --git a/pubspec.lock b/pubspec.lock index 4ef276279..e82963440 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1084,7 +1084,7 @@ packages: source: hosted version: "1.10.2" sqlite3: - dependency: "direct dev" + dependency: "direct main" description: name: sqlite3 sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" diff --git a/pubspec.yaml b/pubspec.yaml index 50be9eff4..135fc1f79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,6 +98,11 @@ dependencies: # 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 + # The synchronous SQLite API. Not used to open anything in app code — it is + # here for two types: the in-memory test helper wraps one handle with + # SqliteDatabase.singleConnection, and the location track's read-only open + # factory has to name `Database` to override sqlite_async's connection hook. + sqlite3: ^3.5.2 talker_flutter: ^5.1.9 url_launcher: ^6.3.2 @@ -105,10 +110,6 @@ 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 diff --git a/release_highlights/lib/26.1/advanced.dart b/release_highlights/lib/26.1/advanced.dart index a84799ed6..9209f5586 100644 --- a/release_highlights/lib/26.1/advanced.dart +++ b/release_highlights/lib/26.1/advanced.dart @@ -1,6 +1,6 @@ // Version-highlight card content for DPIP 26.1 (advanced). // -// GENERATED from `release_highlights/assets/26.1/advanced/cards.json` by `tool/gen/arb/json_to_dart_highlights.py` — edit the +// GENERATED from `release_highlights/assets/26.1/advanced/cards.json` by `tool/gen/release_highlights.py` — edit the // JSON, not this file. Rendering lives in `lib/features/release_highlights`; // this package carries only data. library; diff --git a/release_highlights/lib/26.1/normal.dart b/release_highlights/lib/26.1/normal.dart index ba0dc308f..4afcef91f 100644 --- a/release_highlights/lib/26.1/normal.dart +++ b/release_highlights/lib/26.1/normal.dart @@ -1,6 +1,6 @@ // Version-highlight card content for DPIP 26.1 (normal). // -// GENERATED from `release_highlights/assets/26.1/normal/cards.json` by `tool/gen/arb/json_to_dart_highlights.py` — edit the +// GENERATED from `release_highlights/assets/26.1/normal/cards.json` by `tool/gen/release_highlights.py` — edit the // JSON, not this file. Rendering lives in `lib/features/release_highlights`; // this package carries only data. library; diff --git a/shaders/cloud/clouds.frag b/shaders/cloud/clouds.frag index ef3520465..ac4c253f4 100644 --- a/shaders/cloud/clouds.frag +++ b/shaders/cloud/clouds.frag @@ -234,12 +234,16 @@ void main() { vec3 normal = normalize(mapN); // The fourth light is light 0 with z mirrored — the reference's back light. - vec3 lightDir3 = vec3(iLightDir0.xy, -iLightDir0.z); + // Mirroring z only flips the sign of that one term, so the two dot products + // are the same plane term plus and minus the same depth term: compute each + // once and the back light costs an add rather than a second dot. + float plane0 = dot(iLightDir0.xy, normal.xy); + float depth0 = iLightDir0.z * normal.z; - float l0 = max(dot(iLightDir0, normal), 0.0) * ONE_OVER_PI; + float l0 = max(plane0 + depth0, 0.0) * ONE_OVER_PI; float l1 = max(dot(iLightDir1, normal), 0.0) * ONE_OVER_PI; float l2 = max(dot(iLightDir2, normal), 0.0) * ONE_OVER_PI; - float l3 = max(dot(lightDir3, normal), 0.0) * ONE_OVER_PI; + float l3 = max(plane0 - depth0, 0.0) * ONE_OVER_PI; // How thin the cloud is here. Thin cloud transmits, so it is brighter and // takes the silver-lining rim. diff --git a/test/app/router/notification_routes_test.dart b/test/app/router/notification_routes_test.dart index aad99e198..026516605 100644 --- a/test/app/router/notification_routes_test.dart +++ b/test/app/router/notification_routes_test.dart @@ -15,37 +15,53 @@ void main() { AppRoutes.more, AppRoutes.meshtastic, }; - const knownGroups = { - 'group_eew', - 'group_eq', - 'group_info', - 'group_tsunami', - 'group_mesh', - 'group_other', - }; + // The routing table is keyed per channel, so a newly declared channel no + // longer inherits a destination from its group. This is the test that makes + // that safe: it fails on the first channel missing a row, which is the only + // reason routing per channel is defensible at all. + test('every declared channel has a row in the routing table', () { + final declared = [ + for (final channel in NotificationChannels.channels) channel.channelKey!, + ]; + final missing = [ + for (final key in declared) + if (!notificationChannelRoutes.containsKey(key)) key, + ]; + expect( + missing, + isEmpty, + reason: + 'add these to notificationChannelRoutes in notification_routes.dart', + ); + }); - test('every alert channel is in a known group and routes to a valid tab', () { - for (final channel in NotificationChannels.channels) { - // Non-alert service channels (e.g. `background`) have no group and aren't - // navigation targets — they're excluded from the routing invariant. - if (channel.channelGroupKey == null) continue; - final key = channel.channelKey!; - expect( - NotificationChannels.groupOf(key), - isIn(knownGroups), - reason: 'channel $key has an unmapped group', - ); + // The mirror of the above: a row whose channel no longer exists is dead + // weight that reads as coverage. + test('the routing table has no row for a channel that was removed', () { + final declared = { + for (final channel in NotificationChannels.channels) channel.channelKey!, + }; + final stale = [ + for (final key in notificationChannelRoutes.keys) + if (!declared.contains(key)) key, + ]; + expect(stale, isEmpty, reason: 'these channels no longer exist'); + }); + + test('every channel resolves to a route the shell can actually show', () { + for (final entry in notificationChannelRoutes.entries) { expect( - routeForNotificationChannel(key), + entry.value, isIn(validRoutes), - reason: 'channel $key resolves to an invalid route', + reason: 'channel ${entry.key} resolves to an invalid route', ); } }); - test('the groupless background service channel falls back home', () { - expect(NotificationChannels.groupOf('background'), isNull); - expect(routeForNotificationChannel('background'), AppRoutes.home); + test('the background service channel resolves without a warning', () { + // Not an alert and never tapped, but it is a declared channel, so it needs + // a row or the coverage test above fails. + expect(notificationChannelRoutes['background'], AppRoutes.home); }); test('routes each family to the expected screen', () { @@ -53,11 +69,10 @@ void main() { routeForNotificationChannel('eew_alert-important-v2'), AppRoutes.eew, ); - expect(routeForNotificationChannel('eq-v2'), AppRoutes.earthquake); - expect( - routeForNotificationChannel('int_report-general-v2'), - AppRoutes.earthquake, - ); + // The intra-family split: 震度速報 is a live broadcast and opens the + // monitor, while a 地震報告 is a finished record and opens the list. + expect(routeForNotificationChannel('eq-v2'), AppRoutes.eew); + expect(routeForNotificationChannel('int_report-general-v2'), AppRoutes.eew); expect( routeForNotificationChannel('report-general-v2'), AppRoutes.earthquake, @@ -69,6 +84,267 @@ void main() { ); }); + // The bug this guards: a server push is rendered from awesome's own wire + // format, where the channel is a model field and `payload` is absent. Reading + // the channel from the payload alone left every remote tap with a null + // channel, and every remote tap landed on Home. + test('a push tap carries its channel on the action, not the payload', () { + final tap = NotificationTap.fromData( + null, + channelKey: 'eew_alert-important-v2', + ); + expect(tap.channelKey, 'eew_alert-important-v2'); + expect(routeForNotificationChannel(tap.channelKey), AppRoutes.eew); + }); + + test('a locally displayed tap still routes off its payload', () { + // Notifications this app posts itself do carry `channel` — the service puts + // it there — and must keep working with no action channel supplied. + final tap = NotificationTap.fromData({ + 'channel': 'report-general-v2', + 'id': '42', + }); + expect(tap.channelKey, 'report-general-v2'); + expect(tap.id, '42'); + expect(routeForNotificationChannel(tap.channelKey), AppRoutes.earthquake); + }); + + test('the action channel wins over a stale payload channel', () { + final tap = NotificationTap.fromData({ + 'channel': 'announcement-general-v2', + }, channelKey: 'mesh_message'); + expect(tap.channelKey, 'mesh_message'); + expect(routeForNotificationChannel(tap.channelKey), AppRoutes.meshtastic); + }); + + // What the device actually delivers: awesome hands the tap a payload of + // exactly one key, `content`, holding the producer's JSON. The id lives in + // there and nowhere else. + test('a push tap unpacks the id out of the nested content', () { + final tap = NotificationTap.fromData({ + 'content': + '{"id":1897213924,"channelKey":"int_report-general-v2",' + '"body":"…","notificationLayout":"BigText"}', + }, channelKey: 'int_report-general-v2'); + expect(tap.channelKey, 'int_report-general-v2'); + expect(tap.id, '1897213924'); + expect(routeForNotificationChannel(tap.channelKey), AppRoutes.eew); + }); + + test('a malformed content string still routes', () { + final tap = NotificationTap.fromData({ + 'content': 'not json', + }, channelKey: 'report-general-v2'); + expect(tap.channelKey, 'report-general-v2'); + expect(tap.id, isNull); + expect(routeForNotificationChannel(tap.channelKey), AppRoutes.earthquake); + }); + + test('every url-destination channel also has a route to fall back to', () { + for (final key in notificationChannelUrls.keys) { + expect( + notificationChannelRoutes, + contains(key), + reason: '$key opens a URL but has nowhere to land if it will not open', + ); + expect(Uri.tryParse(notificationChannelUrls[key]!)?.hasScheme, isTrue); + } + }); + + test('an announcement tap opens the web, and does not navigate', () { + final opened = []; + final navigated = []; + routeNotificationTap( + const NotificationTap(channelKey: 'announcement-general-v2'), + navigate: ( + name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) => navigated.add(name), + launch: (url) async { + opened.add(url); + return true; + }, + ); + + expect(opened, [Uri.parse('https://announcement.exptech.com.tw/')]); + expect(navigated, isEmpty); + }); + + test('an announcement tap that will not open falls back into the app', () { + final navigated = []; + routeNotificationTap( + const NotificationTap(channelKey: 'announcement-general-v2'), + navigate: ( + name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) => navigated.add(name), + launch: (url) async => false, + ); + + // The launch is async, so the fallback lands after the microtask drains. + return Future.delayed(Duration.zero, () { + expect(navigated, [AppRoutes.home]); + }); + }); + + test('a launcher that throws still lands the tap somewhere', () { + final navigated = []; + routeNotificationTap( + const NotificationTap(channelKey: 'announcement-general-v2'), + navigate: ( + name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) => navigated.add(name), + launch: (url) async => throw Exception('no browser'), + ); + + return Future.delayed(Duration.zero, () { + expect(navigated, [AppRoutes.home]); + }); + }); + + test('a report tap with a target opens that report, not the list', () { + final navigated = <({String name, Map params})>[]; + routeNotificationTap( + NotificationTap.fromData({ + 'content': + '{"id":123,"channelKey":"report-general-v2",' + '"extra":{"reportId":"115058-2026-0827-054720"}}', + }, channelKey: 'report-general-v2'), + navigate: ( + name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) => navigated.add((name: name, params: pathParameters)), + ); + + // Records compare their fields with `==`, and two equal Maps are not + // identical, so the pair is checked apart rather than as one value. + expect(navigated, hasLength(1)); + expect(navigated.single.name, AppRoutes.earthquakeReport); + expect(navigated.single.params, {'id': '115058-2026-0827-054720'}); + }); + + test('a report tap without a target still opens the list', () { + final navigated = []; + routeNotificationTap( + NotificationTap.fromData({ + 'content': '{"id":123,"channelKey":"report-general-v2"}', + }, channelKey: 'report-general-v2'), + navigate: ( + name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) => navigated.add(name), + ); + + expect(navigated, [AppRoutes.earthquake]); + }); + + test('an empty target is treated as absent', () { + expect( + detailFor( + const NotificationTap( + channelKey: 'report-general-v2', + data: {'reportId': ''}, + ), + ), + isNull, + ); + }); + + test('the report id never collides with the notification id', () { + // The producer sends both; they are different things and different keys. + final tap = NotificationTap.fromData({ + 'content': + '{"id":123456,"channelKey":"report-general-v2",' + '"extra":{"reportId":"115058-2026-0827-054720"}}', + }, channelKey: 'report-general-v2'); + expect(tap.id, '123456'); + expect(tap.data[notificationTargetKey], '115058-2026-0827-054720'); + }); + + test('every detail channel also has a list route to fall back to', () { + for (final key in notificationChannelDetailRoutes.keys) { + expect( + notificationChannelRoutes, + contains(key), + reason: '$key has a detail route but nowhere to land without a target', + ); + } + }); + + test('extra wins over a model field of the same name', () { + // The whole reason extra is a namespace: `id` belongs to awesome's model, + // and a producer that puts one in extra means the app's. + final tap = NotificationTap.fromData({ + 'content': '{"id":123,"channelKey":"eq-v2","extra":{"id":"mine"}}', + }, channelKey: 'eq-v2'); + expect(tap.id, 'mine'); + }); + + test('extra is accepted as a JSON string as well as an object', () { + final tap = NotificationTap.fromData({ + 'content': + '{"channelKey":"report-general-v2",' + '"extra":"{\\"reportId\\":\\"115058-2026-0827-054720\\"}"}', + }, channelKey: 'report-general-v2'); + expect(tap.data[notificationTargetKey], '115058-2026-0827-054720'); + }); + + test('neither container leaks into the data map under its own name', () { + final tap = NotificationTap.fromData({ + 'content': '{"channelKey":"eq-v2","extra":{"a":"1"}}', + }, channelKey: 'eq-v2'); + expect(tap.data.containsKey('content'), isFalse); + expect(tap.data.containsKey('extra'), isFalse); + expect(tap.data['a'], '1'); + }); + + test('a malformed extra does not stop the tap routing', () { + final tap = NotificationTap.fromData({ + 'content': '{"channelKey":"report-general-v2","extra":"not json"}', + }, channelKey: 'report-general-v2'); + expect(tap.data[notificationTargetKey], isNull); + expect(routeForNotificationChannel(tap.channelKey), AppRoutes.earthquake); + }); + + // The iOS wire shape: awesome parses `content` into its model and keeps only + // the fields it knows, so application data has to ride in `payload` — the + // slot the model reserves for it. A custom key is dropped, and the tap + // arrives with an empty payload. + test('a report tap reads its target out of content.payload', () { + final tap = NotificationTap.fromData({ + 'content': + '{"id":123,"channelKey":"report-general-v2",' + '"payload":{"reportId":"115058-2026-0827-054720"}}', + }, channelKey: 'report-general-v2'); + expect(tap.data[notificationTargetKey], '115058-2026-0827-054720'); + expect(tap.id, '123'); + }); + + test('a target that already sits flat in the payload still works', () { + // What iOS hands over once awesome has done the parsing: the model's + // payload map, delivered directly with no `content` wrapper left. + final tap = NotificationTap.fromData({ + 'reportId': '115058-2026-0827-054720', + }, channelKey: 'report-general-v2'); + expect(tap.data[notificationTargetKey], '115058-2026-0827-054720'); + }); + test('an unknown or null channel falls back to home', () { expect(routeForNotificationChannel('does-not-exist'), AppRoutes.home); expect(routeForNotificationChannel(null), AppRoutes.home); diff --git a/test/core/geo/location_track_test.dart b/test/core/geo/location_track_test.dart new file mode 100644 index 000000000..8daca0065 --- /dev/null +++ b/test/core/geo/location_track_test.dart @@ -0,0 +1,258 @@ +/// The delta format, from the writer's side to the reader's. +/// +/// The native stores write this file and Dart only reads it, so nothing in the +/// app exercises both halves — a disagreement about the encoding would show up +/// as a track that is simply wrong, on a device, with no exception anywhere. +/// [_writeFixture] below is therefore a line-by-line restatement of what +/// `LocationTrackStore.swift` and `LocationTrackStore.kt` do, and these tests +/// assert the reader inverts it exactly. +library; + +import 'dart:io'; + +import 'package:dpip/core/geo/location_track.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; + +const _anchorEvery = 64; +const _scale = 10000.0; + +/// Writes fixes the way the native stores do: deltas, absolute every 64th row. +void _writeFixture(String path, List<(int, double, double)> fixes) { + final db = sqlite3.sqlite3.open(path); + db.execute('PRAGMA auto_vacuum=INCREMENTAL'); + db.execute('PRAGMA journal_mode=WAL'); + db.execute( + 'CREATE TABLE IF NOT EXISTS fix (' + 'id INTEGER PRIMARY KEY, t INTEGER NOT NULL, ' + 'lat INTEGER NOT NULL, lng INTEGER NOT NULL)', + ); + + for (final (time, latitude, longitude) in fixes) { + final lat = (latitude * _scale).round(); + final lng = (longitude * _scale).round(); + + final last = + db.select('SELECT IFNULL(MAX(id), 0) AS n FROM fix').first['n'] as int; + var rowid = last + 1; + final previous = rowid % _anchorEvery == 0 ? null : _lastAbsolute(db); + if (previous == null && rowid % _anchorEvery != 0) { + rowid += _anchorEvery - rowid % _anchorEvery; + } + + db.execute('INSERT INTO fix (id, t, lat, lng) VALUES (?, ?, ?, ?)', [ + rowid, + previous == null ? time : time - previous.$1, + previous == null ? lat : lat - previous.$2, + previous == null ? lng : lng - previous.$3, + ]); + } + db.close(); +} + +(int, int, int)? _lastAbsolute(sqlite3.Database db) { + final last = + db.select('SELECT IFNULL(MAX(id), 0) AS n FROM fix').first['n'] as int; + if (last == 0) return null; + final anchor = last - last % _anchorEvery; + (int, int, int)? current; + for (final row in db.select( + 'SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id', + [anchor], + )) { + final id = row['id'] as int; + final t = row['t'] as int; + final lat = row['lat'] as int; + final lng = row['lng'] as int; + current = (id % _anchorEvery == 0 || current == null) + ? (t, lat, lng) + : (current.$1 + t, current.$2 + lat, current.$3 + lng); + } + return current; +} + +/// A plausible walk: a fix a minute, drifting a few metres each time. +List<(int, double, double)> _walk(int count, {int from = 1735689600}) => [ + for (var i = 0; i < count; i++) + (from + i * 60, 25.0330 + i * 0.0003, 121.5654 + i * 0.0002), +]; + +void main() { + late Directory dir; + late String path; + + setUp(() { + dir = Directory.systemTemp.createTempSync('location_track_test'); + path = '${dir.path}/location_track.db'; + }); + + tearDown(() => dir.deleteSync(recursive: true)); + + test('no file is an empty history, not an error', () { + expect(LocationTrack.at(path), isNull); + }); + + test('a single fix round-trips', () async { + _writeFixture(path, [(1735689600, 25.0330, 121.5654)]); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final fixes = await track.since(DateTime.utc(2000)); + expect(fixes, hasLength(1)); + expect( + fixes.single.time, + DateTime.fromMillisecondsSinceEpoch(1735689600 * 1000, isUtc: true), + ); + expect(fixes.single.latitude, closeTo(25.0330, 1e-9)); + expect(fixes.single.longitude, closeTo(121.5654, 1e-9)); + }); + + test('every fix survives several anchor boundaries', () async { + // 200 rows crosses the 64-row boundary three times, so the reader has to + // switch between "absolute" and "add to the running total" repeatedly. A + // one-row-out mistake there decodes into positions that drift. + final expected = _walk(200); + _writeFixture(path, expected); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final fixes = await track.since(DateTime.utc(2000)); + expect(fixes, hasLength(expected.length)); + for (var i = 0; i < expected.length; i++) { + final (time, latitude, longitude) = expected[i]; + expect( + fixes[i].time.millisecondsSinceEpoch ~/ 1000, + time, + reason: 'time at $i', + ); + // Four decimals is what the store keeps, so that is the tolerance. + expect(fixes[i].latitude, closeTo(latitude, 5e-5), reason: 'lat at $i'); + expect(fixes[i].longitude, closeTo(longitude, 5e-5), reason: 'lng at $i'); + } + }); + + test( + 'a window starting mid-group still decodes absolute positions', + () async { + // The point of the anchor walk: row 100 is a delta, meaningless on its + // own. Asking for a window that begins there must produce the same + // coordinates as reading the whole track, not a delta read as a position. + final expected = _walk(200); + _writeFixture(path, expected); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final all = await track.since(DateTime.utc(2000)); + final from = all[100].time; + final window = await track.since(from); + + expect(window, hasLength(all.length - 100)); + expect(window.first.latitude, closeTo(all[100].latitude, 1e-9)); + expect(window.first.longitude, closeTo(all[100].longitude, 1e-9)); + expect(window.last.latitude, closeTo(all.last.latitude, 1e-9)); + }, + ); + + test('a window older than the whole track returns all of it', () async { + _writeFixture(path, _walk(70)); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + expect(await track.since(DateTime.utc(1990)), hasLength(70)); + }); + + test('limit keeps the most recent fixes', () async { + final expected = _walk(200); + _writeFixture(path, expected); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final all = await track.since(DateTime.utc(2000)); + final tail = await track.since(DateTime.utc(2000), limit: 10); + expect(tail, hasLength(10)); + expect(tail.last.time, all.last.time); + expect(tail.first.time, all[190].time); + }); + + test('the first fix lands on an anchor rowid', () { + // The writer has nothing to subtract from on the very first fix, so it + // moves the row up to the next multiple of 64 rather than writing an + // absolute value at a rowid the reader would treat as a delta. Everything + // below depends on this, so it is asserted rather than assumed. + _writeFixture(path, _walk(3)); + final db = sqlite3.sqlite3.open(path); + addTearDown(db.close); + final ids = db + .select('SELECT id FROM fix ORDER BY id') + .map((row) => row['id'] as int) + .toList(); + expect(ids, [_anchorEvery, _anchorEvery + 1, _anchorEvery + 2]); + }); + + test('the track still decodes after the oldest groups are evicted', () async { + // What native eviction does: delete whole anchor groups off the front. + // The remaining rows still read correctly only because the first survivor + // is itself an anchor — drop one row fewer and every position after it + // would be a delta read as a coordinate, somewhere off the coast. + final expected = _walk(200); + _writeFixture(path, expected); + + // Rows start at rowid 64, so expected[i] is at rowid 64 + i. Deleting + // below 128 takes the first whole group, expected[0..63]. + const dropped = _anchorEvery; + final writer = sqlite3.sqlite3.open(path); + writer.execute('DELETE FROM fix WHERE id < ${_anchorEvery * 2}'); + writer.close(); + + final track = LocationTrack.at(path)!; + addTearDown(track.close); + final fixes = await track.since(DateTime.utc(2000)); + + expect(fixes, hasLength(expected.length - dropped)); + final (time, latitude, longitude) = expected[dropped]; + expect(fixes.first.time.millisecondsSinceEpoch ~/ 1000, time); + expect(fixes.first.latitude, closeTo(latitude, 5e-5)); + expect(fixes.first.longitude, closeTo(longitude, 5e-5)); + // And the tail is untouched by the eviction. + expect(fixes.last.latitude, closeTo(expected.last.$2, 5e-5)); + }); + + test('stats report the row count and the file size', () async { + _writeFixture(path, _walk(200)); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final stats = await track.stats(); + expect(stats.fixes, 200); + expect(stats.bytes, greaterThan(0)); + }); + + test('the connection cannot write', () async { + // The separation of duties is enforced by the connection, not by care: + // the native side owns every write, including eviction. + _writeFixture(path, _walk(10)); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + await expectLater( + track.connection.execute('DELETE FROM fix'), + throwsA(isA()), + ); + }); + + test('the encoding stays compact', () async { + // The claim behind the 50 MB budget is roughly 14 bytes a row. This does + // not pin the exact figure — page overhead and the WAL move it — but it + // does fail if someone stores absolutes again, which would roughly double + // it and quietly halve how much history fits. + _writeFixture(path, _walk(20000)); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final stats = await track.stats(); + expect(stats.fixes, 20000); + expect( + stats.bytes / stats.fixes, + lessThan(20), + reason: '${stats.bytes} bytes for ${stats.fixes} fixes', + ); + }); +} diff --git a/test/core/network/not_found_failure_test.dart b/test/core/network/not_found_failure_test.dart new file mode 100644 index 000000000..e098dae75 --- /dev/null +++ b/test/core/network/not_found_failure_test.dart @@ -0,0 +1,45 @@ +import 'package:dio/dio.dart'; +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Failure of(int code) => mapException( + DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: code, + ), + ), +); + +void main() { + // A 404 is the one status where retrying can never succeed, so it gets its + // own type and callers send the reader somewhere real instead. + test('404 maps to NotFoundFailure', () { + expect(of(404), isA()); + }); + + test('other bad responses stay retryable NetworkFailures', () { + for (final code in [400, 401, 403, 429, 500, 502, 503]) { + expect( + of(code), + isA(), + reason: '$code should keep the retry affordance', + ); + } + }); + + test('a timeout is still a TimeoutFailure', () { + expect( + mapException( + DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.receiveTimeout, + ), + ), + isA(), + ); + }); +} diff --git a/test/core/platform/background_location_test.dart b/test/core/platform/background_location_test.dart index fc422f45f..fc84afff6 100644 --- a/test/core/platform/background_location_test.dart +++ b/test/core/platform/background_location_test.dart @@ -83,6 +83,50 @@ void main() { }, ); + test('clearTrack asks the native side to delete, never Dart', () async { + final service = BackgroundLocationService( + platform: 0, + version: '1', + channel: channel, + ); + + await service.clearTrack(); + + // The whole point of the round trip. The recorder caches its database + // handle for the life of the process, so a file unlinked from Dart would + // leave it writing into a dead inode — the rows return on the next read + // and the space never comes back. One side owns every write, this one too. + expect(calls.single.method, 'clearTrack'); + expect(calls.single.arguments, isNull); + }); + + test('clearTrack survives a platform with no such method', () async { + messenger.setMockMethodCallHandler(channel, null); + final service = BackgroundLocationService( + platform: 0, + version: '1', + channel: channel, + ); + + // A developer-page button must not throw on a platform that never recorded + // anything — nothing to clear is not a failure. + await expectLater(service.clearTrack(), completes); + }); + + test('clearTrack swallows a platform failure', () async { + messenger.setMockMethodCallHandler( + channel, + (call) async => throw PlatformException(code: 'disk'), + ); + final service = BackgroundLocationService( + platform: 0, + version: '1', + channel: channel, + ); + + await expectLater(service.clearTrack(), completes); + }); + test('a platform failure is swallowed, not thrown', () async { messenger.setMockMethodCallHandler( channel, diff --git a/test/core/settings/region_store_test.dart b/test/core/settings/region_store_test.dart index 07ed03d24..11e947169 100644 --- a/test/core/settings/region_store_test.dart +++ b/test/core/settings/region_store_test.dart @@ -41,6 +41,24 @@ void main() { }, ); + test('selectedCode answers for every area kind', () async { + // Nine screens used to switch over the area themselves; they now read this. + // 全國 and a fix-less 所在地 must both stay null, or a nationwide feed + // starts claiming to be local. + final store = await makeStore(['100']); + + store.select(0); + expect(store.selectedCode, isNull, reason: '全國 has no township'); + + store.select(1); + expect(store.selectedCode, isNull, reason: '所在地 without a GPS fix'); + store.setCurrentCode('200'); + expect(store.selectedCode, '200'); + + store.select(2); + expect(store.selectedCode, '100'); + }); + test('addSaved caps at 3, dedups, and persists codes', () async { final store = await makeStore(); expect(store.addSaved('100'), isTrue); diff --git a/test/features/bug_tracker/bug_repository_test.dart b/test/features/bug_tracker/bug_repository_test.dart index 3d54b51d6..2c7d43d81 100644 --- a/test/features/bug_tracker/bug_repository_test.dart +++ b/test/features/bug_tracker/bug_repository_test.dart @@ -1,6 +1,11 @@ /// The bug-tracker wire → domain parsers, against payloads shaped like the -/// live tracker's updated contract (2026-08-24): a `users` directory keyed by +/// live tracker's updated contract (2026-08-27): a `users` directory keyed by /// Discord snowflake plus `threads`/`msg` entries that reference it by id. +/// +/// The two endpoints spell tags differently — the index sends the forum's +/// slugs, the detail endpoint still reflects Discord's bilingual labels — so +/// the fixtures deliberately use both forms and both must canonicalise the +/// same way. library; import 'package:dpip/features/bug_tracker/data/bug_repository_impl.dart'; @@ -22,7 +27,7 @@ void main() { { 'threads_id': 1541158207970349066, 'title': 'ET-2026-0122 DPIP3.9過段時間,程式會重製', - 'tags': ['DPIP', '臭蟲 bug'], + 'tags': ['dpip', 'bug'], 'body': '我的dpip放一段時間,在按進去它就會回到是否同意以上...', 'author': _chenId, 'created_at': 1787511150, @@ -36,8 +41,8 @@ void main() { expect(threads, hasLength(1)); expect(threads.first.id, 1541158207970349066); - // The routing marker is stripped; the bilingual label keeps its head. - expect(threads.first.tags, ['臭蟲']); + // The routing marker is stripped; the slug survives verbatim. + expect(threads.first.tags, ['bug']); expect(threads.first.authorName, '陳'); expect( threads.first.createdAt.toUtc(), @@ -45,6 +50,27 @@ void main() { ); }); + test('the index accepts the detail endpoint\'s bilingual labels too', () { + final threads = parseBugThreads({ + 'users': {_chenId: _user(_chenId, '陳')}, + 'threads': [ + { + 'threads_id': 1, + 'title': 't', + 'tags': ['DPIP', '臭蟲 bug', '已解決 fixed'], + 'body': 'b', + 'author': _chenId, + 'created_at': 1787511150, + 'last_message_id': 1, + }, + ], + }); + + // Canonical form is the slug, so the English tail wins and the routing + // marker matches whichever way the server spelt it. + expect(threads.single.tags, ['bug', 'fixed']); + }); + test('threads without the DPIP routing tag are dropped', () { Map thread(int id, List tags) => { 'threads_id': id, @@ -59,20 +85,21 @@ void main() { final threads = parseBugThreads({ 'users': {}, 'threads': [ - thread(1, ['DPIP']), + thread(1, ['dpip']), thread(2, []), - thread(3, ['OTHER']), + thread(3, ['station']), + thread(4, ['DPIP']), ], }); - expect([for (final t in threads) t.id], [1]); + expect([for (final t in threads) t.id], [4, 1]); }); test('the index is ordered by last activity, not source order', () { Map thread(int id) => { 'threads_id': id, 'title': 't$id', - 'tags': ['DPIP'], + 'tags': ['dpip'], 'body': 'b', 'author': _chenId, 'created_at': 1787511150, @@ -92,7 +119,7 @@ void main() { Map thread(int id, {required bool locked}) => { 'threads_id': id, 'title': 't$id', - 'tags': ['DPIP'], + 'tags': ['dpip'], 'body': 'b', 'author': _chenId, 'created_at': 1787511150, @@ -118,7 +145,7 @@ void main() { { 'threads_id': 9, 'title': 't9', - 'tags': ['DPIP'], + 'tags': ['dpip'], 'body': null, 'author': _chenId, 'created_at': 1787511150, @@ -170,7 +197,7 @@ int staff0() => 879008115696230430; Map _threadShell({required int id, required int author}) => { 'threads_id': id, 'title': 't$id', - 'tags': ['DPIP'], + 'tags': ['dpip'], 'body': 'b', 'author': author, 'created_at': 1787511150, diff --git a/test/features/home/weather_sky/card_water_field_test.dart b/test/features/home/weather_sky/card_water_field_test.dart index 01714cb11..250592e75 100644 --- a/test/features/home/weather_sky/card_water_field_test.dart +++ b/test/features/home/weather_sky/card_water_field_test.dart @@ -40,6 +40,39 @@ void _run( void main() { TestWidgetsFlutterBinding.ensureInitialized(); + /// The neighbour search is a hash grid whose slot table is sized from + /// [CardWaterField.capacity]. The solver accumulates pair by pair and + /// floating-point addition does not commute, so if the table's layout ever + /// leaked into the order pairs are visited, two fields that differ only in a + /// cap neither of them reaches would drift apart. They must not. + test('the neighbour grid\'s table size is not observable', () { + String trace(int capacity, CardWaterPreset preset, double seconds) { + final field = _field(capacity: capacity); + _run(field, seconds, preset: preset); + final p = field.debugPositions; + final v = field.debugVelocities; + return [ + field.liveCount, + for (var i = 0; i < p.length; i++) + '${p[i].dx},${p[i].dy},${v[i].dx},${v[i].dy}', + ].join('|'); + } + + for (final preset in const [ + CardWaterPreset.light, + CardWaterPreset.moderate, + CardWaterPreset.heavy, + ]) { + for (final seconds in const [4.0, 25.0]) { + expect( + trace(200, preset, seconds), + trace(700, preset, seconds), + reason: 'caps 200 and 700 diverged after $seconds s', + ); + } + } + }); + test('drops are born just above the edge, not a third of a screen up', () { // the emitter's spawn y is 0.91, but that is an absolute world y — the collision box // puts the card's top face at +0.852, so the spawn line is only 0.058 above diff --git a/test/features/map/presentation/layers/typhoon_weather_chrome_test.dart b/test/features/map/presentation/layers/typhoon_weather_chrome_test.dart index 609452807..db57bf31a 100644 --- a/test/features/map/presentation/layers/typhoon_weather_chrome_test.dart +++ b/test/features/map/presentation/layers/typhoon_weather_chrome_test.dart @@ -74,7 +74,10 @@ class _FakeTyphoonRepository implements MeteorTyphoonRepository { class _FakeRadarRepository extends FakeRasterFrameSource implements RadarRepository { - _FakeRadarRepository() : super(['1700000000']); + _FakeRadarRepository() : super(['1700000000']) { + sourceMinZoom = 3; + sourceMaxZoom = 12; + } @override String tileUrl(String frame) => 'https://host/radar/$frame/{z}/{x}/{y}.webp'; @@ -82,7 +85,10 @@ class _FakeRadarRepository extends FakeRasterFrameSource class _FakeSatelliteRepository extends FakeRasterFrameSource implements SatelliteRepository { - _FakeSatelliteRepository() : super(['1700000000']); + _FakeSatelliteRepository() : super(['1700000000']) { + sourceMinZoom = 0; + sourceMaxZoom = 11; + } @override String tileUrl(String frame) => 'https://host/sat/$frame/{z}/{x}/{y}.png'; @@ -115,6 +121,23 @@ Future _drain() async { } void main() { + test( + 'each weather underlay carries its own published source range', + () async { + final layer = _layer(); + final controller = await _rendered(layer); + + expect(controller.sourceProperties['typhoon-wx-src']?['minzoom'], 3.0); + expect(controller.sourceProperties['typhoon-wx-src']?['maxzoom'], 12.0); + + layer.setWeatherOverlay(TyphoonWeatherOverlay.satellite); + await _drain(); + + expect(controller.sourceProperties['typhoon-wx-src']?['minzoom'], 0.0); + expect(controller.sourceProperties['typhoon-wx-src']?['maxzoom'], 11.0); + }, + ); + test('the borders stay over the underlay across a re-sync', () async { // The chrome sync is diff-based — it re-adds a border only when its toggle // changes — so anything that re-mounts the raster has to leave the borders diff --git a/test/features/map/raster_source_maxzoom_test.dart b/test/features/map/raster_source_maxzoom_test.dart index 14c8ce6cf..747024522 100644 --- a/test/features/map/raster_source_maxzoom_test.dart +++ b/test/features/map/raster_source_maxzoom_test.dart @@ -28,8 +28,10 @@ class _CappedRadarRepository extends FakeRasterFrameSource } void main() { - test('mounted radar sources carry the pyramid cap as maxzoom', () async { - final source = _CappedRadarRepository(_ids(9))..sourceMaxZoom = 8; + test('mounted radar sources carry both pyramid bounds', () async { + final source = _CappedRadarRepository(_ids(9)) + ..sourceMinZoom = 3 + ..sourceMaxZoom = 8; final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -45,6 +47,13 @@ void main() { ); expect(mounted, isNotEmpty, reason: 'the ring must have mounted sources'); for (final entry in mounted.entries) { + expect( + entry.value['minzoom'], + 3.0, + reason: + '${entry.key} must not request placeholder tiles below the ' + 'published pyramid', + ); expect( entry.value['maxzoom'], 8.0, @@ -56,10 +65,10 @@ void main() { } }); - test('an uncapped fake keeps the old behaviour', () async { + test('an unconfigured fake keeps the MapLibre raster defaults', () 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". + // means "no cap" for the fake rather than leaking another source's range. 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 030f6ad44..1c7c9e547 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -32,6 +32,10 @@ abstract class FakeRasterFrameSource implements RasterFrameSource { @override int sourceMaxZoom = 22; + /// 同上:fake 一律不帶下限,行為與舊的無 minzoom 掛載一致。 + @override + int sourceMinZoom = 0; + /// One entry per [warmFrameTiles] call: the frames it was asked to warm. final List> warmed = []; diff --git a/test/features/map/wind_forecast_layer_test.dart b/test/features/map/wind_forecast_layer_test.dart index 2b785e700..fe11e4f61 100644 --- a/test/features/map/wind_forecast_layer_test.dart +++ b/test/features/map/wind_forecast_layer_test.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart'; -import 'package:dpip/features/map/presentation/widgets/wind_particle_overlay.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'; @@ -11,7 +10,6 @@ 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'; import 'raster_timeline_harness.dart'; @@ -87,15 +85,6 @@ 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']), @@ -322,234 +311,6 @@ void main() { ); }); - testWidgets('the overlay slot hosts the particle animation', (tester) async { - await tester.pumpWidget( - const MaterialApp(home: Scaffold(body: SizedBox())), - ); - final layer = WindForecastMapLayer( - _FakeWindRepository(const []), - model: WindForecastModel.ecmwf, - referenceOutline: testReferenceOutline(), - ); - final overlay = layer.buildMapOverlay( - tester.element(find.byType(Scaffold)), - ); - expect(overlay, isA()); - }); - - testWidgets('the ticker runs only while a wind field is loaded', ( - tester, - ) async { - final layer = WindForecastMapLayer( - _FakeWindRepository(const []), - model: WindForecastModel.gfs, - referenceOutline: testReferenceOutline(), - ); - // The harness camera is zoom 7 over Taiwan, so a seeded particle must sit - // inside the viewport and the ticker has somewhere to streak it. - await layer.onAttached(RecordingMapController()); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold(body: WindParticleOverlay(layer: layer)), - ), - ); - expect( - tester.binding.transientCallbackCount, - 0, - reason: 'an empty layer must not run the animation at all', - ); - - layer.field.value = WindField( - width: 2, - height: 2, - lat0: 90, - lon0: 0, - dLat: -90, - dLon: 180, - uMin: -20, - uMax: 20, - vMin: -1, - vMax: 1, - timeMs: 0, - model: 'gfs', - u: Uint8List.fromList([255, 255, 255, 255]), - v: Uint8List.fromList([128, 128, 128, 128]), - ); - await tester.pump(); - - expect( - tester.binding.transientCallbackCount, - greaterThan(0), - reason: 'a loaded field must start the ticker that redraws the streaks', - ); - expect(tester.takeException(), isNull); - }); - - testWidgets('the overlay paints visible streaks, not nothing', ( - tester, - ) async { - tester.view.physicalSize = const Size(1170, 2532); - tester.view.devicePixelRatio = 3.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final layer = WindForecastMapLayer( - _FakeWindRepository(const []), - model: WindForecastModel.gfs, - referenceOutline: testReferenceOutline(), - ); - await layer.onAttached(RecordingMapController()); - layer.field.value = WindField( - width: 2, - height: 2, - lat0: 90, - lon0: 0, - dLat: -90, - dLon: 180, - uMin: -20, - uMax: 20, - vMin: -1, - vMax: 1, - timeMs: 0, - model: 'gfs', - u: Uint8List.fromList([255, 255, 255, 255]), - v: Uint8List.fromList([128, 128, 128, 128]), - ); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - // Black underneath: the streaks are white, and the default Scaffold - // background is near-white, so on that a blank overlay counts as - // bright everywhere and the assertion below means nothing. - body: RepaintBoundary( - child: ColoredBox( - color: Colors.black, - child: WindParticleOverlay(layer: layer), - ), - ), - ), - ), - ); - // Let the seeded particles accumulate a few frames of trail. - for (var i = 0; i < 20; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - - final boundary = tester.renderObject( - find.byType(RepaintBoundary).first, - ); - // toImage is a real engine async — it must run outside the test's fake - // clock, or the future never completes. - ByteData? data; - await tester.runAsync(() async { - final image = await boundary.toImage(); - data = await image.toByteData(); - image.dispose(); - }); - expect(data, isNotNull); - final bytes = data!; - - // The painter draws white-on-dark streaks; at least a few sampled pixels - // must be bright — zero means the CustomPaint produced nothing visible. - var bright = 0; - for (var i = 0; i < bytes.lengthInBytes; i += 16) { - final r = bytes.getUint8(i); - final g = bytes.getUint8(i + 1); - final b = bytes.getUint8(i + 2); - if (r > 160 && g > 160 && b > 160) bright++; - } - expect( - bright, - greaterThan(10), - reason: 'the particle trails must paint as visible bright pixels', - ); - expect(tester.takeException(), isNull); - }); - - testWidgets('streaks appear on a field that arrives after the first build', ( - tester, - ) async { - // The field is fetched asynchronously, so in the app it is always null when - // the overlay first builds — and the painter is handed the simulation by - // value at build time. Without a rebuild when the field lands, the overlay - // paints nothing until some unrelated cause rebuilds it, which in practice - // meant the first pan. Nothing here ever touches the camera. - tester.view.physicalSize = const Size(1170, 2532); - tester.view.devicePixelRatio = 3.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final layer = WindForecastMapLayer( - _FakeWindRepository(const []), - model: WindForecastModel.gfs, - referenceOutline: testReferenceOutline(), - ); - await layer.onAttached(RecordingMapController()); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - // Black underneath: the streaks are white, and the default Scaffold - // background is near-white, so on that a blank overlay counts as - // bright everywhere and the assertion below means nothing. - body: RepaintBoundary( - child: ColoredBox( - color: Colors.black, - child: WindParticleOverlay(layer: layer), - ), - ), - ), - ), - ); - - layer.field.value = WindField( - width: 2, - height: 2, - lat0: 90, - lon0: 0, - dLat: -90, - dLon: 180, - uMin: -20, - uMax: 20, - vMin: -1, - vMax: 1, - timeMs: 0, - model: 'gfs', - u: Uint8List.fromList([255, 255, 255, 255]), - v: Uint8List.fromList([128, 128, 128, 128]), - ); - for (var i = 0; i < 20; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - - final boundary = tester.renderObject( - find.byType(RepaintBoundary).first, - ); - ByteData? data; - await tester.runAsync(() async { - final image = await boundary.toImage(); - data = await image.toByteData(); - image.dispose(); - }); - final bytes = data!; - var bright = 0; - for (var i = 0; i < bytes.lengthInBytes; i += 16) { - if (bytes.getUint8(i) > 160 && - bytes.getUint8(i + 1) > 160 && - bytes.getUint8(i + 2) > 160) { - bright++; - } - } - expect( - bright, - greaterThan(10), - reason: 'a field loaded after mount must still start the streaks', - ); - expect(tester.takeException(), isNull); - }); - testWidgets('the options chip offers county, township, and name toggles', ( tester, ) async { diff --git a/test/features/map/wind_overlay_resilience_test.dart b/test/features/map/wind_overlay_resilience_test.dart deleted file mode 100644 index 1fdc437c2..000000000 --- a/test/features/map/wind_overlay_resilience_test.dart +++ /dev/null @@ -1,394 +0,0 @@ -/// That the wind field keeps animating — through a bad frame, and through the -/// camera settles that used to rebuild it from scratch. -/// -/// The freeze this pins is a property of [Ticker]: it reschedules itself -/// *after* the callback returns, so one throw inside the tick stops the -/// animation for the rest of the session. Worse, `isActive` still reports -/// `true` afterwards, so the overlay's own "start it if it isn't running" -/// check sees a healthy ticker and never restarts it. The field freezes and no -/// amount of panning, zooming or re-selecting the layer brings it back — which -/// is exactly what a user sees after holding the map a while. -library; - -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'; -import 'package:dpip/features/map/presentation/widgets/wind_particle_overlay.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'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'raster_timeline_harness.dart'; - -class _Repo extends FakeRasterFrameSource implements WindForecastRepository { - _Repo(super.frames); - - @override - String tileUrl(String frame) => 'https://host/$frame/{z}/{x}/{y}.webp'; - - @override - Future> fetchWindField(String frame) async => Ok( - WindField( - width: 8, - height: 5, - lat0: 90, - lon0: 0, - dLat: -45, - dLon: 45, - uMin: -30, - uMax: 30, - vMin: -30, - vMax: 30, - timeMs: 0, - model: 'gfs', - u: Uint8List(40)..fillRange(0, 40, 220), - v: Uint8List(40)..fillRange(0, 40, 40), - ), - ); -} - -/// A controller whose camera can be made to throw or to go missing, standing -/// in for the bad frame — in the app it is `context.size` during a -/// layout-dirty rebuild, or a controller detached from the layer. -class _FlakyController extends RecordingMapController { - bool throwOnCamera = false; - bool cameraMissing = false; - - /// Drives rotation — the gesture that used to break the streaks. - double bearing = 0; - double zoom = 5; - - /// How many times the tick asked for the camera — the proof it is still - /// running. - int reads = 0; - - @override - CameraPosition? get cameraPosition { - reads++; - if (throwOnCamera) throw StateError('camera unavailable this frame'); - if (cameraMissing) return null; - return CameraPosition( - target: const LatLng(23.5, 121), - zoom: zoom, - bearing: bearing, - ); - } -} - -final GlobalKey _boundaryKey = GlobalKey(); - -Future<(WindForecastMapLayer, _FlakyController)> _mount( - WidgetTester tester, { - bool boundary = false, -}) async { - final layer = WindForecastMapLayer( - _Repo(['1700000000']), - model: WindForecastModel.gfs, - referenceOutline: testReferenceOutline(), - ); - final controller = _FlakyController(); - final frames = (await layer.frames()).valueOrNull!; - await layer.prepare(controller, frames); - await layer.show(controller, frames.first); - - Widget overlay = SizedBox( - width: 400, - height: 800, - child: WindParticleOverlay(layer: layer), - ); - if (boundary) { - // A dark ground so the white streaks read, inside a boundary the test can - // rasterise. - overlay = RepaintBoundary( - key: _boundaryKey, - child: ColoredBox(color: const Color(0xFF000000), child: overlay), - ); - } - await tester.pumpWidget(MaterialApp(home: Scaffold(body: overlay))); - await tester.pump(const Duration(milliseconds: 16)); - return (layer, controller); -} - -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)); - expect(controller.reads, greaterThan(0), reason: 'not ticking at all'); - - // One bad frame. - controller.throwOnCamera = true; - await tester.pump(const Duration(milliseconds: 16)); - controller.throwOnCamera = false; - - // …and the next frames still arrive. Without the guard the ticker never - // reschedules and this count stops moving for good. - final after = controller.reads; - for (var i = 0; i < 5; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - expect( - controller.reads, - greaterThan(after), - reason: 'the ticker died on the bad frame and never came back', - ); - expect(tester.takeException(), isNull, reason: 'it must not surface'); - }); - - testWidgets('a run of bad frames still recovers', (tester) async { - final (_, controller) = await _mount(tester); - controller.throwOnCamera = true; - for (var i = 0; i < 30; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - controller.throwOnCamera = false; - final after = controller.reads; - for (var i = 0; i < 5; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - expect(controller.reads, greaterThan(after)); - }); - - testWidgets('a stall is diagnosed by name, and the field then resumes', ( - tester, - ) async { - Log.talker.history.clear(); - final (_, controller) = await _mount(tester); - - // Wedge it: frames stop being produced while the overlay still believes it - // is animating — which is what every remaining unknown looks like from the - // outside. - controller.cameraMissing = true; - for (var i = 0; i < 200; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - - // The watchdog names the guard that is holding. Without this line a report - // is "the particles froze", which is not something anyone can fix. - final stalls = Log.talker.history - .map((e) => e.message ?? '') - .where((m) => m.contains('wind particles stalled')) - .toList(); - expect(stalls, isNotEmpty, reason: 'a wedged field said nothing'); - expect(stalls.first, contains('no camera')); - // Reported once per stall, not once a second for as long as it lasts. - expect(stalls, hasLength(1)); - - controller.cameraMissing = false; - final after = controller.reads; - for (var i = 0; i < 5; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - expect( - controller.reads, - greaterThan(after), - reason: 'the field stayed frozen after the stall cleared', - ); - }); - - testWidgets('a healthy field is never restarted', (tester) async { - // The watchdog must not churn a working animation: it drops the trail - // buffer, so a spurious restart would show as a visible flicker every - // second. - final (_, controller) = await _mount(tester); - // At frame cadence: one `pump` is one frame however far it advances the - // clock, so pumping in 300 ms steps would model a 3 fps device and the - // watchdog would rightly call that a stall. - for (var i = 0; i < 200; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - // Three seconds of healthy animation, and it was never restarted — a - // restart clears the trail buffer, so this is what keeps the streaks from - // blinking once a second. - expect(controller.reads, greaterThan(150)); - expect(find.byType(WindParticleOverlay), findsOneWidget); - }); - - testWidgets('backgrounding stops the watchdog and resume restarts frames', ( - tester, - ) async { - Log.talker.history.clear(); - final (_, controller) = await _mount(tester); - await tester.pump(const Duration(milliseconds: 16)); - expect( - controller.reads, - greaterThan(0), - reason: 'not ticking before pause', - ); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); - addTearDown(() { - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - }); - await tester.pump(); - final pausedAt = controller.reads; - await tester.pump(const Duration(seconds: 3)); - - expect(controller.reads, pausedAt, reason: 'ticker ran in the background'); - expect( - Log.talker.history - .map((event) => event.message ?? '') - .where((message) => message.contains('wind particles stalled')), - isEmpty, - reason: 'the watchdog treated an app pause as an animation failure', - ); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(); - for (var i = 0; i < 5; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - expect( - controller.reads, - greaterThan(pausedAt), - reason: 'frames did not restart after returning to the app', - ); - }); - - test('the particle overlay is not rebuilt on every camera settle', () { - // The scaffold keys the overlay subtree by the camera epoch so a - // screen-space callout reprojects. This overlay reads the live camera on - // every tick instead, and re-keying it would tear down the ticker, the - // simulation and the trail buffer on every pan, zoom and tap. - final wind = WindForecastMapLayer( - _Repo(const ['1']), - model: WindForecastModel.gfs, - referenceOutline: testReferenceOutline(), - ); - expect(wind.overlayFollowsCamera, isFalse); - }); - - testWidgets('the streaks keep changing while the map rotates', ( - tester, - ) async { - // The reported failure: rotate, and the particles freeze on an old picture - // while everything behind them keeps running — the simulation steps, the - // ticker fires, the painter is marked dirty. Only the painted output stops - // moving, so nothing short of reading the pixels can see it. - final (_, controller) = await _mount(tester, boundary: true); - - Future shot() async { - final object = - _boundaryKey.currentContext!.findRenderObject()! - as RenderRepaintBoundary; - late ByteData data; - await tester.runAsync(() async { - final image = await object.toImage(); - data = (await image.toByteData())!; - image.dispose(); - }); - return data; - } - - // Spin the map, the way a two-finger twist does. - for (var i = 0; i < 20; i++) { - controller.bearing += 3; - await tester.pump(const Duration(milliseconds: 16)); - } - final first = await shot(); - - for (var i = 0; i < 20; i++) { - controller.bearing += 3; - await tester.pump(const Duration(milliseconds: 16)); - } - final second = await shot(); - - var differing = 0; - for (var i = 0; i < first.lengthInBytes; i += 4) { - if (first.getUint32(i) != second.getUint32(i)) differing++; - } - expect( - differing, - greaterThan(100), - reason: 'the streaks froze on a stale picture while the map rotated', - ); - }); - - testWidgets('a gesture clears the field, and releasing reseeds it', ( - tester, - ) async { - final (layer, controller) = await _mount(tester, boundary: true); - for (var i = 0; i < 30; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - - Future litPixels() async { - final object = - _boundaryKey.currentContext!.findRenderObject()! - as RenderRepaintBoundary; - late ByteData data; - await tester.runAsync(() async { - final image = await object.toImage(); - data = (await image.toByteData())!; - image.dispose(); - }); - var lit = 0; - for (var i = 0; i < data.lengthInBytes; i += 4) { - if (data.getUint8(i) > 40) lit++; - } - return lit; - } - - expect(await litPixels(), greaterThan(0), reason: 'nothing drawn at rest'); - - // A finger goes down. - layer.onMapGestureStart(); - await tester.pump(); - expect( - await litPixels(), - 0, - reason: 'the field must be gone for the whole gesture', - ); - - // …and nothing is computed while it is held. This is the point of the - // whole thing: a pan, pinch or rotate costs this page nothing. - final duringStart = controller.reads; - for (var i = 0; i < 30; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - expect( - controller.reads, - duringStart, - reason: 'the simulation kept running through the gesture', - ); - expect(await litPixels(), 0); - - // Pinch to a new final zoom while the field is absent, then release. The - // recreated simulation must seed against this settled camera rather than - // resize the old population throughout the pinch. - controller.zoom = 7; - layer.onMapGestureEnd(); - await tester.pump(); - for (var i = 0; i < 30; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } - expect(controller.reads, greaterThan(duringStart)); - expect(await litPixels(), greaterThan(0), reason: 'it never came back'); - }); - - test('the layer reports the gesture the scaffold hands it', () { - final layer = WindForecastMapLayer( - _Repo(const ['1']), - model: WindForecastModel.gfs, - referenceOutline: testReferenceOutline(), - ); - expect(layer.interacting.value, isFalse); - layer.onMapGestureStart(); - expect(layer.interacting.value, isTrue); - layer.onMapGestureEnd(); - expect(layer.interacting.value, isFalse); - }); -} diff --git a/test/features/map/wind_particle_native_test.dart b/test/features/map/wind_particle_native_test.dart index 851314610..6e46318b6 100644 --- a/test/features/map/wind_particle_native_test.dart +++ b/test/features/map/wind_particle_native_test.dart @@ -40,8 +40,10 @@ void main() { expect(tuning['zoomHi'], kWindZoomHi); expect(tuning['particlesLo'], kWindParticles.$1); expect(tuning['particlesHi'], kWindParticles.$2); - expect(tuning['pointSizeLo'], kWindPointSize.$1); - expect(tuning['pointSizeHi'], kWindPointSize.$2); + for (var i = 0; i < kWindLineWidths.length; i++) { + expect(tuning['lineWidthZ${i + 3}'], kWindLineWidths[i]); + } + expect(tuning['particleWidth'], kWindParticleWidth); expect(tuning['speedFactorLo'], kWindSpeedFactor.$1); expect(tuning['speedFactorHi'], kWindSpeedFactor.$2); expect(tuning['fadeOpacityLo'], kWindFadeOpacity.$1); @@ -57,12 +59,7 @@ void main() { // 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', - ]) { + for (final base in const ['particles', 'speedFactor', 'fadeOpacity']) { expect(tuning, contains('${base}Lo'), reason: base); expect(tuning, contains('${base}Hi'), reason: base); expect( @@ -187,4 +184,32 @@ void main() { await expectLater(build(TargetPlatform.android).detach(), completes); }); }); + + group('render-loop gating', () { + final field = WindField.fromWnd1(_wnd1()); + + test('runs only for a visible decoded field with a stable camera', () { + expect( + windParticleShouldPlay(visible: true, interacting: false, field: field), + isTrue, + ); + expect( + windParticleShouldPlay(visible: true, interacting: true, field: field), + isFalse, + reason: 'camera gestures pause GPU state and prevent projection jumps', + ); + expect( + windParticleShouldPlay( + visible: false, + interacting: false, + field: field, + ), + isFalse, + ); + expect( + windParticleShouldPlay(visible: true, interacting: false, field: null), + isFalse, + ); + }); + }); } diff --git a/test/features/map/wind_web_parity_test.dart b/test/features/map/wind_web_parity_test.dart index 89bbc7a60..66f84fb6e 100644 --- a/test/features/map/wind_web_parity_test.dart +++ b/test/features/map/wind_web_parity_test.dart @@ -1,4 +1,7 @@ import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; + +import 'dart:math' as math; + import 'package:flutter_test/flutter_test.dart'; /// Every number here was computed by running `effective()` from @@ -32,12 +35,19 @@ void main() { }); }); - test('point size matches the web (TUNE: zoom lin)', () { - expect(pointSizeFor(3), closeTo(1.5, 1e-9)); - expect(pointSizeFor(4), closeTo(1.575, 1e-9)); - expect(pointSizeFor(5), closeTo(1.65, 1e-9)); - expect(pointSizeFor(6), closeTo(1.725, 1e-9)); - expect(pointSizeFor(7), closeTo(1.8, 1e-9)); + test('line width matches the reference integer-zoom table', () { + expect(lineWidthFor(3), closeTo(1, 1e-9)); + expect(lineWidthFor(4), closeTo(1.2, 1e-9)); + expect(lineWidthFor(5), closeTo(1.6, 1e-9)); + expect(lineWidthFor(6), closeTo(1.8, 1e-9)); + expect(lineWidthFor(7), closeTo(2, 1e-9)); + expect(lineWidthFor(4.5), closeTo(1.4, 1e-9)); + }); + + test('rendered stroke includes the reference physical-pixel fringe', () { + // z6: max(1, 1.8 × 1.3 × 2.625) + one physical AA pixel. + expect(pointSizeFor(6, pixelRatio: 2.625), closeTo(7.1425 / 2.625, 1e-9)); + expect(pointSizeFor(3), closeTo(2.3, 1e-9)); }); test('field step matches the web (TUNE: zoom log)', () { @@ -54,11 +64,26 @@ void main() { expectStep(7, 0.0151); }); - group('fade opacity (TUNE: zoom lin)', () { - test('matches the web at every stop', () { - expect(fadeOpacityFor(3), closeTo(0.95, 1e-9)); - expect(fadeOpacityFor(5), closeTo(0.9475, 1e-9)); - expect(fadeOpacityFor(7), closeTo(0.945, 1e-9)); + group('fade opacity', () { + // Deliberately NOT the reference's 0.97. DPIP renders the short dash the + // old Flutter overlay drew — 14 frames of history — rather than Windy's + // second-long streak, and 0.85 is where the exponential lands the same + // window (`0.85^14 = 0.10`). See kWindFadeOpacity for the reasoning. + test('is constant across the zoom range', () { + expect(fadeOpacityFor(3), closeTo(0.85, 1e-9)); + expect(fadeOpacityFor(5), closeTo(0.85, 1e-9)); + expect(fadeOpacityFor(7), closeTo(0.85, 1e-9)); + }); + + test('keeps a stroke visible for roughly the intended window', () { + // The look this replaced showed 14 frames of history and nothing older. + final f = fadeOpacityFor(5); + expect( + math.pow(f, 14), + lessThan(0.15), + reason: 'the tail should be gone', + ); + expect(math.pow(f, 5), greaterThan(0.3), reason: 'but not immediately'); }); test('never reaches 1, at any zoom', () { diff --git a/test/features/status/server_status_page_test.dart b/test/features/status/server_status_page_test.dart index 645894bf4..c0934dee0 100644 --- a/test/features/status/server_status_page_test.dart +++ b/test/features/status/server_status_page_test.dart @@ -79,7 +79,8 @@ void main() { final l10n = l10nOf(tester); expect(find.text(l10n.serverStatusDown), findsWidgets); expect(find.text('2'), findsOneWidget); - expect(find.text('0.90%'), findsOneWidget); + // 去尾零:0.9 不再顯示為 0.90(三位數字上限的新格式)。 + expect(find.text('0.9%'), findsOneWidget); expect(find.text('800ms'), findsOneWidget); }); diff --git a/test/shared/map/map_style_test.dart b/test/shared/map/map_style_test.dart index 476bba396..c9b541407 100644 --- a/test/shared/map/map_style_test.dart +++ b/test/shared/map/map_style_test.dart @@ -14,6 +14,10 @@ void main() { ), ) as Map; + final base = style['sources']['exptech'] as Map; + expect(base['minzoom'], 0); + expect(base['maxzoom'], basemapSourceMaxZoom); + final layers = style['layers'] as List; final ids = [for (final l in layers) (l as Map)['id']]; expect(ids, [ @@ -57,7 +61,9 @@ void main() { 'tiny (a whole-island view is one or two 512px tiles, vs ~49 at a ' 'zoomed-in hillshade viewport)', ); - expect(terrain['maxzoom'], 12); + // z11 overzoom 與原生 z12 hillshade 的中位 SSIM 0.982、海拔 RMSE + // 1.56m;z10 在山區已有明顯差異,因此只省掉最後一層。 + expect(terrain['maxzoom'], terrainSourceMaxZoom); expect( terrain['bounds'], [110, 10, 132, 35], diff --git a/test/shared/map/map_tile_cache_test.dart b/test/shared/map/map_tile_cache_test.dart index 18829e51c..1d3c927ec 100644 --- a/test/shared/map/map_tile_cache_test.dart +++ b/test/shared/map/map_tile_cache_test.dart @@ -59,6 +59,9 @@ final class _TestFrameRepository extends FrameTileRepository { @override int get sourceMaxZoom => maxZoom; + @override + int get sourceMinZoom => 0; + @override String get tilePathPrefix => '/api/v2/tiles/radar/'; diff --git a/tool/check/storage.sh b/tool/check/storage.sh index 8f743f136..7a2a85ad3 100755 --- a/tool/check/storage.sh +++ b/tool/check/storage.sh @@ -25,7 +25,8 @@ lib/core/storage/app_database.dart lib/core/astro/tle_store.dart lib/core/meshtastic/data/mesh_store.dart lib/core/network/etag_cache_store.dart -lib/core/network/network_usage_store.dart' +lib/core/network/network_usage_store.dart +lib/core/geo/location_track.dart' fail=0 while IFS= read -r file; do diff --git a/tool/dev/build.sh b/tool/dev/build.sh index afdfbf158..bad92f944 100755 --- a/tool/dev/build.sh +++ b/tool/dev/build.sh @@ -2,6 +2,7 @@ # A release build, for the platform named first. # # tool/dev/build.sh android # APK → build/app/outputs/flutter-apk/ +# tool/dev/build.sh android-debug # debug-signed APK, installable over run.sh # tool/dev/build.sh bundle # AAB → what Play actually takes # tool/dev/build.sh ios # unsigned, for a local check # @@ -24,14 +25,22 @@ cd "$(repo_root)" target="${1:-}" shift || true +# A debug-signed APK, for installing over a `tool/run.sh` build. +# +# Release and debug are signed with different keys, so `adb install -r` refuses +# to replace one with the other — and uninstalling to get around it takes the +# app's permissions, settings and databases with it. Without this an agent (or +# anyone iterating on native code) has to hand every build back to a human to +# install, which is exactly how three rounds of shader fixes reached nobody. case "$target" in - android) pinned flutter build apk --release "$@" ;; + android) pinned flutter build apk --release "$@" ;; + android-debug) pinned flutter build apk --debug "$@" ;; bundle) pinned flutter build appbundle --release "$@" ;; # No --release: `flutter build ios` is release by default, and hardcoding it # would fight the `--debug` the iOS smoke-test build passes. ios) pinned flutter build ios --no-codesign "$@" ;; *) - printf 'usage: tool/dev/build.sh {android|bundle|ios} [flutter build args]\n' >&2 + printf 'usage: tool/dev/build.sh {android|android-debug|bundle|ios} [flutter build args]\n' >&2 exit 2 ;; esac diff --git a/tool/gen/arb/add_changelog_github_key.py b/tool/gen/arb/add_changelog_github_key.py deleted file mode 100644 index 772e5fc46..000000000 --- a/tool/gen/arb/add_changelog_github_key.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -"""Insert the `changelogOpenOnGitHub` key after each file's changelogBodyEmpty -line. Idempotent. Run from repo root, then gen-l10n. -""" - -import pathlib - -ROOT = pathlib.Path(__file__).resolve().parent.parent - -KEYS = { - "zh_TW": "\u5728 GitHub \u67e5\u770b", - "zh": "\u5728 GitHub \u67e5\u770b", - "zh_Hant_HK": "\u5728 GitHub \u67e5\u770b", - "zh_Hans": "\u5728 GitHub \u67e5\u770b", - "en": "View on GitHub", - "ja": "GitHub \u3067\u898b\u308b", - "ko": "GitHub\uc5d0\uc11c \ubcf4\uae30", - "th": "\u0e14\u0e39\u0e1a\u0e19 GitHub", - "vi": "Xem tr\u00ean GitHub", - "id": "Lihat di GitHub", - "fil": "Tingnan sa GitHub", -} - - -def main() -> None: - for path in sorted((ROOT / "lib/l10n").glob("app_*.arb")): - loc = path.stem[len("app_") :] - original = path.read_text() - if '"changelogOpenOnGitHub"' in original: - print(f"{path.name}: already has key, skipped") - continue - needle = f' "changelogBodyEmpty":' - idx = original.find(needle) - if idx < 0: - print(f"{path.name}: no changelogBodyEmpty anchor, SKIPPED") - continue - line_end = original.index("\n", idx) - line = original[idx:line_end] - if not line.rstrip().endswith(","): - original = original[:line_end] + "," + original[line_end:] - line_end = original.index("\n", idx) - insert = f' "changelogOpenOnGitHub": "{KEYS[loc]}",' - original = original[: line_end + 1] + insert + "\n" + original[line_end + 1 :] - path.write_text(original) - print(f"{path.name}: +changelogOpenOnGitHub") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/add_cloudflare_status_keys.py b/tool/gen/arb/add_cloudflare_status_keys.py deleted file mode 100644 index 66a634770..000000000 --- a/tool/gen/arb/add_cloudflare_status_keys.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 -"""Insert the Cloudflare-status l10n keys after serverStatusWebUrl in every ARB. - -Run from repo root: python3 tool/add_cloudflare_status_keys.py, then gen-l10n. -""" - -import json -import pathlib - -ROOT = pathlib.Path(__file__).resolve().parent.parent -ARB_DIR = ROOT / "lib" / "l10n" - -NEW_KEYS = { - "zh": { - "serverStatusExpTech": "ExpTech 状态", - "serverStatusCloudflare": "Cloudflare 状态", - "serverStatusCloudflareAllOperational": "所有区域正常", - "serverStatusCloudflareOutage": "Cloudflare 部分区域异常", - "serverStatusCloudflareNone": "目前没有可显示的区域。", - "serverStatusCloudflareOperational": "正常", - "serverStatusCloudflareDegraded": "性能下降", - "serverStatusCloudflarePartial": "部分中断", - "serverStatusCloudflareMajor": "大规模中断", - "serverStatusCloudflareUnknown": "未知", - }, - "zh_TW": { - "serverStatusExpTech": "ExpTech 狀態", - "serverStatusCloudflare": "Cloudflare 狀態", - "serverStatusCloudflareAllOperational": "所有區域正常", - "serverStatusCloudflareOutage": "Cloudflare 部分區域異常", - "serverStatusCloudflareNone": "目前沒有可顯示的區域。", - "serverStatusCloudflareOperational": "正常", - "serverStatusCloudflareDegraded": "效能下降", - "serverStatusCloudflarePartial": "部分中斷", - "serverStatusCloudflareMajor": "大規模中斷", - "serverStatusCloudflareUnknown": "未知", - }, - "zh_Hant_HK": { - "serverStatusExpTech": "ExpTech 狀態", - "serverStatusCloudflare": "Cloudflare 狀態", - "serverStatusCloudflareAllOperational": "所有區域正常", - "serverStatusCloudflareOutage": "Cloudflare 部分區域異常", - "serverStatusCloudflareNone": "目前沒有可顯示的區域。", - "serverStatusCloudflareOperational": "正常", - "serverStatusCloudflareDegraded": "效能下降", - "serverStatusCloudflarePartial": "部分中斷", - "serverStatusCloudflareMajor": "大規模中斷", - "serverStatusCloudflareUnknown": "未知", - }, - "zh_Hans": { - "serverStatusExpTech": "ExpTech 状态", - "serverStatusCloudflare": "Cloudflare 状态", - "serverStatusCloudflareAllOperational": "所有区域正常", - "serverStatusCloudflareOutage": "Cloudflare 部分区域异常", - "serverStatusCloudflareNone": "目前没有可显示的区域。", - "serverStatusCloudflareOperational": "正常", - "serverStatusCloudflareDegraded": "性能下降", - "serverStatusCloudflarePartial": "部分中断", - "serverStatusCloudflareMajor": "大规模中断", - "serverStatusCloudflareUnknown": "未知", - }, - "en": { - "serverStatusExpTech": "ExpTech status", - "serverStatusCloudflare": "Cloudflare status", - "serverStatusCloudflareAllOperational": "All regions operational", - "serverStatusCloudflareOutage": "Cloudflare regional issue", - "serverStatusCloudflareNone": "No regions to show.", - "serverStatusCloudflareOperational": "Operational", - "serverStatusCloudflareDegraded": "Degraded", - "serverStatusCloudflarePartial": "Partial outage", - "serverStatusCloudflareMajor": "Major outage", - "serverStatusCloudflareUnknown": "Unknown", - }, - "ja": { - "serverStatusExpTech": "ExpTech ステータス", - "serverStatusCloudflare": "Cloudflare ステータス", - "serverStatusCloudflareAllOperational": "全リージョン正常", - "serverStatusCloudflareOutage": "Cloudflare の一部リージョンで異常", - "serverStatusCloudflareNone": "表示できるリージョンがありません。", - "serverStatusCloudflareOperational": "正常", - "serverStatusCloudflareDegraded": "性能低下", - "serverStatusCloudflarePartial": "部分停止", - "serverStatusCloudflareMajor": "大規模停止", - "serverStatusCloudflareUnknown": "不明", - }, - "ko": { - "serverStatusExpTech": "ExpTech 상태", - "serverStatusCloudflare": "Cloudflare 상태", - "serverStatusCloudflareAllOperational": "모든 리전 정상", - "serverStatusCloudflareOutage": "Cloudflare 일부 리전 이상", - "serverStatusCloudflareNone": "표시할 리전이 없습니다.", - "serverStatusCloudflareOperational": "정상", - "serverStatusCloudflareDegraded": "성능 저하", - "serverStatusCloudflarePartial": "부분 중단", - "serverStatusCloudflareMajor": "대규모 중단", - "serverStatusCloudflareUnknown": "알 수 없음", - }, - "th": { - "serverStatusExpTech": "สถานะ ExpTech", - "serverStatusCloudflare": "สถานะ Cloudflare", - "serverStatusCloudflareAllOperational": "ทุกภูมิภาคปกติ", - "serverStatusCloudflareOutage": "Cloudflare บางภูมิภาคผิดปกติ", - "serverStatusCloudflareNone": "ไม่มีภูมิภาคให้แสดง", - "serverStatusCloudflareOperational": "ปกติ", - "serverStatusCloudflareDegraded": "ประสิทธิภาพลดลง", - "serverStatusCloudflarePartial": "หยุดบางส่วน", - "serverStatusCloudflareMajor": "หยุดบริการขนาดใหญ่", - "serverStatusCloudflareUnknown": "ไม่ทราบ", - }, - "vi": { - "serverStatusExpTech": "Trạng thái ExpTech", - "serverStatusCloudflare": "Trạng thái Cloudflare", - "serverStatusCloudflareAllOperational": "Tất cả khu vực hoạt động", - "serverStatusCloudflareOutage": "Cloudflare có khu vực bất thường", - "serverStatusCloudflareNone": "Không có khu vực nào để hiển thị.", - "serverStatusCloudflareOperational": "Hoạt động", - "serverStatusCloudflareDegraded": "Hiệu suất giảm", - "serverStatusCloudflarePartial": "Gián đoạn một phần", - "serverStatusCloudflareMajor": "Gián đoạn lớn", - "serverStatusCloudflareUnknown": "Không rõ", - }, - "id": { - "serverStatusExpTech": "Status ExpTech", - "serverStatusCloudflare": "Status Cloudflare", - "serverStatusCloudflareAllOperational": "Semua wilayah normal", - "serverStatusCloudflareOutage": "Cloudflare beberapa wilayah bermasalah", - "serverStatusCloudflareNone": "Tidak ada wilayah untuk ditampilkan.", - "serverStatusCloudflareOperational": "Normal", - "serverStatusCloudflareDegraded": "Kinerja menurun", - "serverStatusCloudflarePartial": "Gangguan sebagian", - "serverStatusCloudflareMajor": "Gangguan besar", - "serverStatusCloudflareUnknown": "Tidak diketahui", - }, - "fil": { - "serverStatusExpTech": "Katayuan ng ExpTech", - "serverStatusCloudflare": "Katayuan ng Cloudflare", - "serverStatusCloudflareAllOperational": "Normal ang lahat ng lugar", - "serverStatusCloudflareOutage": "May problema ang Cloudflare sa ilang lugar", - "serverStatusCloudflareNone": "Walang lugar na maipapakita.", - "serverStatusCloudflareOperational": "Normal", - "serverStatusCloudflareDegraded": "Bumaba ang pagganap", - "serverStatusCloudflarePartial": "Bahagyang pagkaantala", - "serverStatusCloudflareMajor": "Malaking pagkaantala", - "serverStatusCloudflareUnknown": "Hindi alam", - }, -} - - -def main() -> None: - for path in sorted(ARB_DIR.glob("app_*.arb")): - locale = path.stem.removeprefix("app_") - insert = NEW_KEYS.get(locale) - if insert is None: - print(f"skip {path.name} (no translations)") - continue - data = json.loads(path.read_text(encoding="utf-8")) - if "serverStatusCloudflare" in data: - print(f"skip {path.name} (already present)") - continue - out = {} - for key, value in data.items(): - out[key] = value - if key == "serverStatusWebUrl": - out.update(insert) - if "serverStatusCloudflare" not in out: - out.update(insert) - path.write_text( - json.dumps(out, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - print(f"updated {path.name}") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/add_eew_source_keys.py b/tool/gen/arb/add_eew_source_keys.py deleted file mode 100644 index f34eddd6d..000000000 --- a/tool/gen/arb/add_eew_source_keys.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -"""Add the EEW-source-filter settings keys to every ARB. - -New settings page (More → Display → EEW source): choose whether the live -monitor / replay / earthquake list show every publishing agency's EEW or only -中央氣象署 (CWA). Run from repo root: python3 tool/gen/arb/add_eew_source_keys.py -""" - -import json -import pathlib - -ROOT = pathlib.Path(__file__).resolve().parent.parent.parent.parent -ARB_DIR = ROOT / "lib" / "l10n" - -LOCALES = { - "zh": { - "eewSourceSettings": "地震速報來源", - "eewSourceSubtitle": "選擇要顯示哪些單位發布的地震速報。", - "eewSourceAll": "所有來源", - "eewSourceAllDescription": "顯示所有機構發布的地震速報。", - "eewSourceCwaOnly": "僅中央氣象署", - "eewSourceCwaOnlyDescription": "只顯示中央氣象署發布的地震速報。", - }, - "zh_TW": { - "eewSourceSettings": "地震速報來源", - "eewSourceSubtitle": "選擇要顯示哪些單位發布的地震速報。", - "eewSourceAll": "所有來源", - "eewSourceAllDescription": "顯示所有機構發布的地震速報。", - "eewSourceCwaOnly": "僅中央氣象署", - "eewSourceCwaOnlyDescription": "只顯示中央氣象署發布的地震速報。", - }, - "zh_Hant_HK": { - "eewSourceSettings": "地震速報來源", - "eewSourceSubtitle": "選擇要顯示哪些機構發布的地震速報。", - "eewSourceAll": "所有來源", - "eewSourceAllDescription": "顯示所有機構發布的地震速報。", - "eewSourceCwaOnly": "僅中央氣象署", - "eewSourceCwaOnlyDescription": "只顯示中央氣象署發布的地震速報。", - }, - "zh_Hans": { - "eewSourceSettings": "地震速报来源", - "eewSourceSubtitle": "选择要显示哪些单位发布的地震速报。", - "eewSourceAll": "所有来源", - "eewSourceAllDescription": "显示所有机构发布的地震速报。", - "eewSourceCwaOnly": "仅中央气象署", - "eewSourceCwaOnlyDescription": "只显示中央气象署发布的地震速报。", - }, - "en": { - "eewSourceSettings": "EEW source", - "eewSourceSubtitle": "Choose which agencies' earthquake early warnings the app shows.", - "eewSourceAll": "All sources", - "eewSourceAllDescription": "Show earthquake early warnings from every publishing agency.", - "eewSourceCwaOnly": "CWA only", - "eewSourceCwaOnlyDescription": "Show only earthquake early warnings published by Taiwan's Central Weather Administration (CWA).", - }, - "ja": { - "eewSourceSettings": "緊急地震速報の情報源", - "eewSourceSubtitle": "表示する緊急地震速報の発表機関を選択します。", - "eewSourceAll": "すべての情報源", - "eewSourceAllDescription": "すべての発表機関の緊急地震速報を表示します。", - "eewSourceCwaOnly": "中央気象署のみ", - "eewSourceCwaOnlyDescription": "台湾中央気象署(CWA)が発表した緊急地震速報のみを表示します。", - }, - "ko": { - "eewSourceSettings": "지진 조기경보 출처", - "eewSourceSubtitle": "표시할 지진 조기경보 발표 기관을 선택하세요.", - "eewSourceAll": "모든 출처", - "eewSourceAllDescription": "모든 발표 기관의 지진 조기경보를 표시합니다.", - "eewSourceCwaOnly": "중앙기상서만", - "eewSourceCwaOnlyDescription": "대만 중앙기상서(CWA)가 발표한 지진 조기경보만 표시합니다.", - }, - "th": { - "eewSourceSettings": "แหล่งที่มาของ EEW", - "eewSourceSubtitle": "เลือกหน่วยงานที่ต้องการแสดงการแจ้งเตือนแผ่นดินไหวล่วงหน้า", - "eewSourceAll": "ทุกแหล่งที่มา", - "eewSourceAllDescription": "แสดงการแจ้งเตือนแผ่นดินไหวล่วงหน้าจากทุกหน่วยงานที่เผยแพร่", - "eewSourceCwaOnly": "เฉพาะ CWA เท่านั้น", - "eewSourceCwaOnlyDescription": "แสดงเฉพาะการแจ้งเตือนที่เผยแพร่โดยสำนักงานอุตุนิยมวิทยากลางไต้หวัน (CWA) เท่านั้น", - }, - "vi": { - "eewSourceSettings": "Nguồn cảnh báo sớm động đất", - "eewSourceSubtitle": "Chọn cơ quan phát hành cảnh báo sớm động đất muốn hiển thị.", - "eewSourceAll": "Tất cả nguồn", - "eewSourceAllDescription": "Hiển thị cảnh báo sớm động đất từ mọi cơ quan phát hành.", - "eewSourceCwaOnly": "Chỉ CWA", - "eewSourceCwaOnlyDescription": "Chỉ hiển thị cảnh báo sớm động đất do Cục Khí tượng Trung ương Đài Loan (CWA) phát hành.", - }, - "id": { - "eewSourceSettings": "Sumber EEW", - "eewSourceSubtitle": "Pilih badan penerbit peringatan dini gempa yang ingin ditampilkan.", - "eewSourceAll": "Semua sumber", - "eewSourceAllDescription": "Tampilkan peringatan dini gempa dari semua badan penerbit.", - "eewSourceCwaOnly": "Hanya CWA", - "eewSourceCwaOnlyDescription": "Hanya tampilkan peringatan dini gempa yang diterbitkan oleh Badan Meteorologi Pusat Taiwan (CWA).", - }, - "fil": { - "eewSourceSettings": "Pinagmulan ng EEW", - "eewSourceSubtitle": "Piliin kung aling mga ahensya ang ipapakitang paunang babala sa lindol.", - "eewSourceAll": "Lahat ng pinagmulan", - "eewSourceAllDescription": "Ipakita ang paunang babala sa lindol mula sa bawat ahensyang naglalabas nito.", - "eewSourceCwaOnly": "CWA lang", - "eewSourceCwaOnlyDescription": "Ipakita lamang ang paunang babala sa lindol na inilabas ng Central Weather Administration (CWA) ng Taiwan.", - }, -} - -# Where to insert the new keys so they stay grouped with the other map/default -# settings-page strings. -ANCHOR = "defaultMapLayerSettings" - - -def main() -> None: - for path in sorted(ARB_DIR.glob("app_*.arb")): - locale = path.stem.removeprefix("app_") - if locale not in LOCALES: - print(f"skip {path.name} (no translations)") - continue - updates = LOCALES[locale] - data = json.loads(path.read_text(encoding="utf-8")) - out = {} - for key, value in data.items(): - out[key] = value - if key == ANCHOR: - for k, v in updates.items(): - out[k] = v - path.write_text( - json.dumps(out, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - print(f"updated {path.name}") - - -if __name__ == "__main__": - main() diff --git a/tool/gen/arb/add_endpoint_health_keys.py b/tool/gen/arb/add_endpoint_health_keys.py deleted file mode 100644 index 3a4cdcd74..000000000 --- a/tool/gen/arb/add_endpoint_health_keys.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 -"""Rewrite the "本機狀態" copy and add the endpoint-health keys in every ARB. - -The local-status block used to be the OS permission checklist; it is now the -client's own reading of the multi-active endpoints (which region actually -answers). Run from repo root: python3 tool/add_endpoint_health_keys.py -""" - -import json -import pathlib - -ROOT = pathlib.Path(__file__).resolve().parent.parent -ARB_DIR = ROOT / "lib" / "l10n" - -# (locale) -> (updated serverStatusLocalBody, new endpoint keys) -LOCALES = { - "zh": { - "serverStatusLocalBody": "伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:", - "endpointHealthOk": "本機連線正常", - "endpointHealthDegraded": "有端點連線不穩", - "endpointHealthDown": "本機連線異常", - "endpointHealthUnknown": "尚無觀測資料", - "endpointHealthNone": "本機尚未對任何端點發出請求。", - "endpointStateOk": "正常", - "endpointStateDegraded": "不穩", - "endpointStateDown": "異常", - "endpointStateUnknown": "未知", - "endpointLastSuccessNever": "尚未成功", - }, - "zh_TW": { - "serverStatusLocalBody": "伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:", - "endpointHealthOk": "本機連線正常", - "endpointHealthDegraded": "有端點連線不穩", - "endpointHealthDown": "本機連線異常", - "endpointHealthUnknown": "尚無觀測資料", - "endpointHealthNone": "本機尚未對任何端點發出請求。", - "endpointStateOk": "正常", - "endpointStateDegraded": "不穩", - "endpointStateDown": "異常", - "endpointStateUnknown": "未知", - "endpointLastSuccessNever": "尚未成功", - }, - "zh_Hant_HK": { - "serverStatusLocalBody": "伺服器指標來自控制台,下方是本機對多活端點(LB / Core 各區)的實際連線判斷:", - "endpointHealthOk": "本機連線正常", - "endpointHealthDegraded": "有端點連線不穩", - "endpointHealthDown": "本機連線異常", - "endpointHealthUnknown": "尚無觀測資料", - "endpointHealthNone": "本機尚未對任何端點發出請求。", - "endpointStateOk": "正常", - "endpointStateDegraded": "不穩", - "endpointStateDown": "異常", - "endpointStateUnknown": "未知", - "endpointLastSuccessNever": "尚未成功", - }, - "zh_Hans": { - "serverStatusLocalBody": "服务器指标来自控制台,下方是本机对多活端点(LB / Core 各区)的实际连接判断:", - "endpointHealthOk": "本机连接正常", - "endpointHealthDegraded": "有端点连接不稳", - "endpointHealthDown": "本机连接异常", - "endpointHealthUnknown": "暂无观测数据", - "endpointHealthNone": "本机尚未对任何端点发出请求。", - "endpointStateOk": "正常", - "endpointStateDegraded": "不稳", - "endpointStateDown": "异常", - "endpointStateUnknown": "未知", - "endpointLastSuccessNever": "尚未成功", - }, - "en": { - "serverStatusLocalBody": "The server metrics above come from the dashboard; below is this device's own view of the multi-active endpoints — which LB / Core region actually answers:", - "endpointHealthOk": "Local connections healthy", - "endpointHealthDegraded": "Some endpoints unstable", - "endpointHealthDown": "Local connections failing", - "endpointHealthUnknown": "No observations yet", - "endpointHealthNone": "This device has not yet sent a request to any endpoint.", - "endpointStateOk": "OK", - "endpointStateDegraded": "Unstable", - "endpointStateDown": "Failing", - "endpointStateUnknown": "Unknown", - "endpointLastSuccessNever": "never succeeded", - }, - "ja": { - "serverStatusLocalBody": "サーバー指標はダッシュボードから取得し、以下はこの端末が実際に接続しているマルチアクティブエンドポイント(LB / Core 各リージョン)の判定です:", - "endpointHealthOk": "接続正常", - "endpointHealthDegraded": "不安定なエンドポイントあり", - "endpointHealthDown": "接続異常", - "endpointHealthUnknown": "観測データなし", - "endpointHealthNone": "この端末はまだどのエンドポイントにもリクエストしていません。", - "endpointStateOk": "正常", - "endpointStateDegraded": "不安定", - "endpointStateDown": "異常", - "endpointStateUnknown": "不明", - "endpointLastSuccessNever": "未成功", - }, - "ko": { - "serverStatusLocalBody": "서버 지표는 대시보드에서 가져오며, 아래는 이 기기가 실제로 연결 중인 멀티 액티브 엔드포인트(LB/Core 각 리전)의 판단입니다:", - "endpointHealthOk": "연결 정상", - "endpointHealthDegraded": "불안정한 엔드포인트 있음", - "endpointHealthDown": "연결 이상", - "endpointHealthUnknown": "관측 데이터 없음", - "endpointHealthNone": "이 기기는 아직 어떤 엔드포인트에도 요청하지 않았습니다.", - "endpointStateOk": "정상", - "endpointStateDegraded": "불안정", - "endpointStateDown": "이상", - "endpointStateUnknown": "알 수 없음", - "endpointLastSuccessNever": "미성공", - }, - "th": { - "serverStatusLocalBody": "ตัวชี้วัดเซิร์ฟเวอร์มาจากแดชบอร์ด ด้านล่างคือการตัดสินของอุปกรณ์นี้ต่อจุดเชื่อมต่อแบบ multi-active (แต่ละภูมิภาคของ LB / Core):", - "endpointHealthOk": "การเชื่อมต่อปกติ", - "endpointHealthDegraded": "มีจุดเชื่อมต่อไม่เสถียร", - "endpointHealthDown": "การเชื่อมต่อผิดปกติ", - "endpointHealthUnknown": "ยังไม่มีข้อมูล", - "endpointHealthNone": "อุปกรณ์นี้ยังไม่ได้ส่งคำขอไปยังจุดเชื่อมต่อใด", - "endpointStateOk": "ปกติ", - "endpointStateDegraded": "ไม่เสถียร", - "endpointStateDown": "ผิดปกติ", - "endpointStateUnknown": "ไม่ทราบ", - "endpointLastSuccessNever": "ยังไม่สำเร็จ", - }, - "vi": { - "serverStatusLocalBody": "Chỉ số máy chủ lấy từ dashboard; bên dưới là nhận định của thiết bị này về các máy chủ multi-active (từng khu vực LB / Core) mà thiết bị thực sự kết nối:", - "endpointHealthOk": "Kết nối bình thường", - "endpointHealthDegraded": "Có máy chủ không ổn định", - "endpointHealthDown": "Kết nối bất thường", - "endpointHealthUnknown": "Chưa có dữ liệu", - "endpointHealthNone": "Thiết bị này chưa gửi yêu cầu đến máy chủ nào.", - "endpointStateOk": "Bình thường", - "endpointStateDegraded": "Không ổn định", - "endpointStateDown": "Bất thường", - "endpointStateUnknown": "Không rõ", - "endpointLastSuccessNever": "chưa thành công", - }, - "id": { - "serverStatusLocalBody": "Metrik server berasal dari dashboard; di bawah ini adalah penilaian perangkat ini terhadap endpoint multi-active (tiap wilayah LB/Core) yang benar-benar terhubung:", - "endpointHealthOk": "Koneksi normal", - "endpointHealthDegraded": "Ada endpoint tidak stabil", - "endpointHealthDown": "Koneksi bermasalah", - "endpointHealthUnknown": "Belum ada data", - "endpointHealthNone": "Perangkat ini belum mengirim permintaan ke endpoint mana pun.", - "endpointStateOk": "Normal", - "endpointStateDegraded": "Tidak stabil", - "endpointStateDown": "Bermasalah", - "endpointStateUnknown": "Tidak diketahui", - "endpointLastSuccessNever": "belum berhasil", - }, - "fil": { - "serverStatusLocalBody": "Ang mga sukatan ng server ay mula sa dashboard; sa ibaba ay ang sariling pagtingin ng device na ito sa mga multi-active endpoint (bawat rehiyon ng LB/Core) na aktwal na kumokonekta:", - "endpointHealthOk": "Normal ang koneksyon", - "endpointHealthDegraded": "May endpoint na hindi matatag", - "endpointHealthDown": "May problema ang koneksyon", - "endpointHealthUnknown": "Wala pang datos", - "endpointHealthNone": "Ang device na ito ay hindi pa nagpapadala ng kahit anong request sa endpoint.", - "endpointStateOk": "Normal", - "endpointStateDegraded": "Hindi matatag", - "endpointStateDown": "May problema", - "endpointStateUnknown": "Hindi alam", - "endpointLastSuccessNever": "hindi pa nagtagumpay", - }, -} - -# Where to insert the new keys so they stay grouped with the server-status ones. -ANCHOR = "serverStatusUpdated" - - -def main() -> None: - for path in sorted(ARB_DIR.glob("app_*.arb")): - locale = path.stem.removeprefix("app_") - if locale not in LOCALES: - print(f"skip {path.name} (no translations)") - continue - updates = LOCALES[locale] - data = json.loads(path.read_text(encoding="utf-8")) - out = {} - for key, value in data.items(): - out[key] = value - if key == "serverStatusLocalBody": - out[key] = updates["serverStatusLocalBody"] - if key == ANCHOR: - for k, v in updates.items(): - if k != "serverStatusLocalBody": - out[k] = v - path.write_text( - json.dumps(out, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - print(f"updated {path.name}") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/add_more_beta_partners.py b/tool/gen/arb/add_more_beta_partners.py deleted file mode 100644 index d84aab889..000000000 --- a/tool/gen/arb/add_more_beta_partners.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -"""Insert the More-page beta/partner keys after each locale's -`"moreSectionApp"` line. Idempotent. Run from repo root, then gen-l10n. -""" - -import pathlib -import re - -ROOT = pathlib.Path(__file__).resolve().parent.parent - -# key -> per-locale value; locale key is the ARB file's locale code -KEYS = { - "moreSectionBeta": { - "zh_TW": "\u6e2c\u8a66\u7248", "zh_Hant_HK": "\u6e2c\u8a66\u7248", - "zh": "\u6e2c\u8a66\u7248", "zh_Hans": "\u6d4b\u8bd5\u7248", - "en": "Beta", "ko": "\ud14c\uc2a4\ud2b8 \ubc84\uc804", "ja": "\u30c6\u30b9\u30c8\u7248", - "vi": "B\u1ea3n th\u1eed nghi\u1ec7m", "id": "Versi uji", - "th": "\u0e40\u0e27\u0e2d\u0e23\u0e4c\u0e0a\u0e31\u0e19\u0e17\u0e14\u0e2a\u0e2d\u0e1a", - "fil": "Bersyon ng pagsubok", - }, - "moreAndroidBeta": { - "zh_TW": "Android \u6e2c\u8a66\u7248", "zh_Hant_HK": "Android \u6e2c\u8a66\u7248", - "zh": "Android \u6e2c\u8a66\u7248", "zh_Hans": "Android \u6d4b\u8bd5\u7248", - "en": "Android beta", "ko": "Android \ud14c\uc2a4\ud2b8 \ubc84\uc804", - "ja": "Android \u30c6\u30b9\u30c8\u7248", "vi": "B\u1ea3n th\u1eed nghi\u1ec7m Android", - "id": "Versi uji Android", "th": "\u0e40\u0e27\u0e2d\u0e23\u0e4c\u0e0a\u0e31\u0e19\u0e17\u0e14\u0e2d\u0e1a Android", - "fil": "Bersyon ng pagsubok sa Android", - }, - "moreTestFlight": { - "zh_TW": "iOS \u6e2c\u8a66\u7248\uff08TestFlight\uff09", "zh_Hant_HK": "iOS \u6e2c\u8a66\u7248\uff08TestFlight\uff09", - "zh": "iOS \u6e2c\u8a66\u7248\uff08TestFlight\uff09", "zh_Hans": "iOS \u6d4b\u8bd5\u7248\uff08TestFlight\uff09", - "en": "iOS beta (TestFlight)", "ko": "iOS \ud14c\uc2a4\ud2b8 \ubc84\uc804 (TestFlight)", - "ja": "iOS \u30c6\u30b9\u30c8\u7248\uff08TestFlight\uff09", - "vi": "B\u1ea3n th\u1eed nghi\u1ec7m iOS (TestFlight)", "id": "Versi uji iOS (TestFlight)", - "th": "\u0e40\u0e27\u0e2d\u0e23\u0e4c\u0e0a\u0e31\u0e19\u0e17\u0e14\u0e2d\u0e1a iOS (TestFlight)", - "fil": "Bersyon ng pagsubok sa iOS (TestFlight)", - }, - "moreSectionPartners": { - "zh_TW": "\u5408\u4f5c\u5925\u4f34", "zh_Hant_HK": "\u5408\u4f5c\u5925\u4f34", - "zh": "\u5408\u4f5c\u5925\u4f34", "zh_Hans": "\u5408\u4f5c\u4f19\u4f34", - "en": "Partners", "ko": "\ud30c\ud2b8\ub108", "ja": "\u30d1\u30fc\u30c8\u30ca\u30fc", - "vi": "\u0110\u1ed1i t\u00e1c", "id": "Mitra", "th": "\u0e1e\u0e31\u0e19\u0e18\u0e21\u0e34\u0e15\u0e23", - "fil": "Mga kasosyo", - }, - "morePartnerGeoscience": { - "zh_TW": "\u5de8\u79d1\u8cc7\u8a0a\u6709\u9650\u516c\u53f8", "zh_Hant_HK": "\u5de8\u79d1\u8cc7\u8a0a\u6709\u9650\u516c\u53f8", - "zh": "\u5de8\u79d1\u8cc7\u8a0a\u6709\u9650\u516c\u53f8", "zh_Hans": "\u5de8\u79d1\u8d44\u8baf\u6709\u9650\u516c\u53f8", - "en": "Geoscience", "ko": "Geoscience", "ja": "Geoscience", "vi": "Geoscience", - "id": "Geoscience", "th": "Geoscience", "fil": "Geoscience", - }, - "morePartnerTwds": { - "zh_TW": "\u53f0\u7063\u6578\u4f4d\u4e32\u6d41\u6709\u9650\u516c\u53f8", "zh_Hant_HK": "\u53f0\u7063\u6578\u4f4d\u4e32\u6d41\u6709\u9650\u516c\u53f8", - "zh": "\u53f0\u7063\u6578\u4f4d\u4e32\u6d41\u6709\u9650\u516c\u53f8", "zh_Hans": "\u53f0\u6e7e\u6570\u4f4d\u4e32\u6d41\u6709\u9650\u516c\u53f8", - "en": "TWDS", "ko": "TWDS", "ja": "TWDS", "vi": "TWDS", "id": "TWDS", "th": "TWDS", - "fil": "TWDS", - }, -} - -def locale_of(path: pathlib.Path) -> str: - return path.stem[len("app_") :] - -def insert_after_app(data: str, lines: list[str]) -> str: - """Insert `lines` after the first '"moreSectionApp"' line.""" - idx = re.search(r'^ "moreSectionApp": ".*?",?$', data, re.M).start() - line_end = data.index("\n", idx) - # Re-add the trailing comma to the anchor if it was the last key in the file - anchor = data[idx : line_end] - if not anchor.rstrip().endswith(","): - data = data[:line_end] + "," + data[line_end:] - new_block = "\n" + "\n".join(" " + ln for ln in lines) - return data[: line_end + 1] + new_block + "\n" + data[line_end + 1 :] - -def main() -> None: - for path in sorted((ROOT / "lib/l10n").glob("app_*.arb")): - loc = locale_of(path) - original = path.read_text() - if f'"moreSectionBeta"' in original: - print(f"{path.name}: already has keys, skipped") - continue - lines = [] - for key, per_locale in KEYS.items(): - lines.append(f'"{key}": "{per_locale[loc]}",') - path.write_text(insert_after_app(original, lines)) - print(f"{path.name}: +{len(lines)} keys after moreSectionApp") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/add_more_partners_note.py b/tool/gen/arb/add_more_partners_note.py deleted file mode 100644 index 4fbed690d..000000000 --- a/tool/gen/arb/add_more_partners_note.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -"""Insert the partners-note key after each locale's `"moreSectionPartners"` -line. Idempotent. Run from repo root, then gen-l10n. -""" - -import pathlib -import re - -ROOT = pathlib.Path(__file__).resolve().parent.parent - -NOTES = { - "zh_TW": "\u4f9d\u6642\u9593\u5408\u4f5c\u79a9\u5e8f\u4f86\u6392\u5217\u3002\u611f\u8b1d\u9019\u4e9b\u500b\u4eba\u8207\u516c\u53f8\u5c0d\u9632\u707d\u4e8b\u696d\u7684\u8ca2\u737b\uff0c\u4ed6\u5011\u7684\u4ed8\u51fa\u8b93 DPIP \u8b8a\u5f97\u53ef\u80fd\u3002", - "zh_Hant_HK": "\u4f9d\u6642\u9593\u5408\u4f5c\u79a9\u5e8f\u4f86\u6392\u5217\u3002\u611f\u8b1d\u9019\u4e9b\u500b\u4eba\u8207\u516c\u53f8\u5c0d\u9632\u707d\u4e8b\u696d\u7684\u8ca2\u737b\uff0c\u4ed6\u5011\u7684\u4ed8\u51fa\u8b93 DPIP \u8b8a\u5f97\u53ef\u80fd\u3002", - "zh": "\u4f9d\u6642\u9593\u5408\u4f5c\u79a9\u5e8f\u4f86\u6392\u5217\u3002\u611f\u8b1d\u9019\u4e9b\u500b\u4eba\u8207\u516c\u53f8\u5c0d\u9632\u707d\u4e8b\u696d\u7684\u8ca2\u737b\uff0c\u4ed6\u5011\u7684\u4ed8\u51fa\u8b93 DPIP \u8b8a\u5f97\u53ef\u80fd\u3002", - "zh_Hans": "\u6309\u65f6\u95f4\u5408\u4f5c\u79e9\u5e8f\u6392\u5217\u3002\u611f\u8c22\u8fd9\u4e9b\u4e2a\u4eba\u4e0e\u516c\u53f8\u5bf9\u9632\u707e\u4e8b\u4e1a\u7684\u8d21\u732e\uff0c\u4ed6\u4eec\u7684\u4ed8\u51fa\u8ba9 DPIP \u53d8\u5f97\u53ef\u80fd\u3002", - "en": "Listed in order of partnership. Thank you to the individuals and companies whose contributions to disaster preparedness made DPIP possible.", - "ko": "\ud30c\ud2b8\ub108\uc2ed \uc21c\uc11c\ub300\ub85c \ud45c\uc2dc\ub429\ub2c8\ub2e4. \uc7ac\ub09c \uc608\ubc29\uc5d0 \uae30\uc5ec\ud55c \uac1c\uc778\uacfc \uae30\uc5c5\uc5d0 \uac10\uc0ac\ub4dc\ub9bd\ub2c8\ub2e4. \uadf8\ub4e4\uc758 \uae30\uc5ec \ub354\ubd09\uc5d0 DPIP\uac00 \uac00\ub2a5\ud588\uc2b5\ub2c8\ub2e4.", - "ja": "\u63d0\u643a\u9806\u306b\u8868\u793a\u3057\u3066\u3044\u307e\u3059\u3002\u9632\u707d\u3078\u306e\u8ca2\u732e\u3067 DPIP \u3092\u652f\u3048\u3066\u304f\u3060\u3055\u3063\u305f\u500b\u4eba\u30fb\u4f01\u696d\u306e\u7686\u69d8\u306b\u611f\u8b1d\u3057\u307e\u3059\u3002", - "vi": "Theo th\u1ee9 t\u1ef1 h\u1ee3p t\u00e1c. Xin c\u1ea3m \u01a1n c\u00e1c c\u00e1 nh\u00e2n v\u00e0 c\u00f4ng ty \u0111\u00e3 \u0111\u00f3ng g\u00f3p cho c\u00f4ng t\u00e1c ph\u00f2ng ch\u1ed1ng thi\u00ean tai, nh\u1edd \u0111\u00f3 DPIP m\u1edbi c\u00f3 th\u1ec3 ra \u0111\u1eddi.", - "id": "Urut sesuai waktu kemitraan. Terima kasih kepada para individu dan perusahaan yang berkontribusi pada penanggulangan bencana; kontribusi mereka membuat DPIP menjadi mungkin.", - "th": "\u0e40\u0e23\u0e35\u0e22\u0e07\u0e15\u0e32\u0e21\u0e25\u0e33\u0e14\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e04\u0e27\u0e32\u0e21\u0e23\u0e48\u0e27\u0e21\u0e21\u0e37\u0e2d \u0e02\u0e2d\u0e1a\u0e04\u0e38\u0e13\u0e1a\u0e38\u0e04\u0e04\u0e25\u0e41\u0e25\u0e30\u0e1a\u0e23\u0e34\u0e29\u0e31\u0e17\u0e17\u0e35\u0e48\u0e21\u0e35\u0e2a\u0e48\u0e27\u0e19\u0e23\u0e48\u0e27\u0e21\u0e43\u0e19\u0e01\u0e32\u0e23\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19\u0e20\u0e31\u0e22\u0e1e\u0e34\u0e1a\u0e31\u0e15\u0e34 \u0e01\u0e32\u0e23\u0e2a\u0e19\u0e31\u0e1a\u0e2a\u0e19\u0e38\u0e19\u0e02\u0e2d\u0e07\u0e1e\u0e27\u0e01\u0e40\u0e02\u0e32\u0e17\u0e33\u0e43\u0e2b\u0e49 DPIP \u0e40\u0e01\u0e34\u0e14\u0e02\u0e36\u0e49\u0e19\u0e44\u0e14\u0e49", - "fil": "Nakaayos ayon sa tamang panahon ng pakikipagtulungan. Salamat sa mga indibidwal at kompanyang nag-ambag sa paghahanda sa kalamidad; ang kanilang kontribusyon ang nagbigay-daan sa DPIP.", -} - -def locale_of(path: pathlib.Path) -> str: - return path.stem[len("app_") :] - -def main() -> None: - for path in sorted((ROOT / "lib/l10n").glob("app_*.arb")): - loc = locale_of(path) - original = path.read_text() - if '"morePartnersNote"' in original: - print(f"{path.name}: already has key, skipped") - continue - anchor = re.search(r'^ "moreSectionPartners": ".*?",?$', original, re.M) - if anchor is None: - print(f"{path.name}: no moreSectionPartners anchor, SKIPPED") - continue - line_end = original.index("\n", anchor.start()) - anchor_line = original[anchor.start() : line_end] - if not anchor_line.rstrip().endswith(","): - original = original[:line_end] + "," + original[line_end:] - line_end = original.index("\n", anchor.start()) - insert = f' "morePartnersNote": "{NOTES[loc]}",' - original = original[: line_end + 1] + insert + "\n" + original[line_end + 1 :] - path.write_text(original) - print(f"{path.name}: +morePartnersNote") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/add_release_highlights_keys.py b/tool/gen/arb/add_release_highlights_keys.py deleted file mode 100644 index 3ea6d63ac..000000000 --- a/tool/gen/arb/add_release_highlights_keys.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python3 -"""Insert the version-highlights l10n keys after the moreVersionNotes block in -every ARB. Idempotent. Run from repo root, then gen-l10n. - -The keys cover only the *chrome* of the version-highlights page — the card -body content itself ships as structured JSON (see 26.1/), so it is not part -of the ARB parity contract. -""" - -import json -import pathlib - -ROOT = pathlib.Path(__file__).resolve().parents[3] -ARB_DIR = ROOT / "lib" / "l10n" - -# locale -> {key: value}. Present keys are skipped per-file. -NEW_KEYS = { - "zh": { - "releaseHighlightsTitle": "本次更新", - "releaseHighlightsTabNormal": "做了哪些改變", - "releaseHighlightsTabAdvanced": "深入技術", - "releaseHighlightsEmpty": "目前沒有內容。", - "releaseHighlightsSeeNotes": "查看完整更新日誌", - "highlightCardTechnical": "技術細節", - }, - "zh_TW": { - "releaseHighlightsTitle": "本次更新", - "releaseHighlightsTabNormal": "做了哪些改變", - "releaseHighlightsTabAdvanced": "深入技術", - "releaseHighlightsEmpty": "目前沒有內容。", - "releaseHighlightsSeeNotes": "查看完整更新日誌", - "highlightCardTechnical": "技術細節", - }, - "zh_Hant_HK": { - "releaseHighlightsTitle": "本次更新", - "releaseHighlightsTabNormal": "做了哪些改變", - "releaseHighlightsTabAdvanced": "深入技術", - "releaseHighlightsEmpty": "目前沒有內容。", - "releaseHighlightsSeeNotes": "查看完整更新日誌", - "highlightCardTechnical": "技術細節", - }, - "zh_Hans": { - "releaseHighlightsTitle": "本次更新", - "releaseHighlightsTabNormal": "做了哪些改变", - "releaseHighlightsTabAdvanced": "深入技术", - "releaseHighlightsEmpty": "目前没有内容。", - "releaseHighlightsSeeNotes": "查看完整更新日志", - "highlightCardTechnical": "技术细节", - }, - "en": { - "releaseHighlightsTitle": "What changed in this release", - "releaseHighlightsTabNormal": "For users", - "releaseHighlightsTabAdvanced": "Deep dive", - "releaseHighlightsEmpty": "Nothing here yet.", - "releaseHighlightsSeeNotes": "Full release notes", - "highlightCardTechnical": "Technical", - }, - "ja": { - "releaseHighlightsTitle": "今回の更新", - "releaseHighlightsTabNormal": "変更点", - "releaseHighlightsTabAdvanced": "技術詳細", - "releaseHighlightsEmpty": "まだコンテンツがありません。", - "releaseHighlightsSeeNotes": "完全なリリースノート", - "highlightCardTechnical": "技術詳細", - }, - "ko": { - "releaseHighlightsTitle": "이번 업데이트", - "releaseHighlightsTabNormal": "변경된 점", - "releaseHighlightsTabAdvanced": "기술 세부", - "releaseHighlightsEmpty": "아직 내용이 없습니다.", - "releaseHighlightsSeeNotes": "전체 릴리스 노트", - "highlightCardTechnical": "기술 세부", - }, - "th": { - "releaseHighlightsTitle": "สิ่งที่เปลี่ยนแปลง", - "releaseHighlightsTabNormal": "สำหรับผู้ใช้", - "releaseHighlightsTabAdvanced": "เจาะลึก", - "releaseHighlightsEmpty": "ยังไม่มีเนื้อหา", - "releaseHighlightsSeeNotes": "ดูบันทึกทั้งหมด", - "highlightCardTechnical": "เทคนิค", - }, - "vi": { - "releaseHighlightsTitle": "Thay đổi trong bản này", - "releaseHighlightsTabNormal": "Cho người dùng", - "releaseHighlightsTabAdvanced": "Đi sâu", - "releaseHighlightsEmpty": "Chưa có nội dung.", - "releaseHighlightsSeeNotes": "Xem ghi chú đầy đủ", - "highlightCardTechnical": "Kỹ thuật", - }, - "id": { - "releaseHighlightsTitle": "Yang berubah", - "releaseHighlightsTabNormal": "Untuk pengguna", - "releaseHighlightsTabAdvanced": "Mendalam", - "releaseHighlightsEmpty": "Belum ada konten.", - "releaseHighlightsSeeNotes": "Catatan rilis lengkap", - "highlightCardTechnical": "Teknis", - }, - "fil": { - "releaseHighlightsTitle": "Ano ang nagbago", - "releaseHighlightsTabNormal": "Para sa mga user", - "releaseHighlightsTabAdvanced": "Mas malalim", - "releaseHighlightsEmpty": "Wala pang laman.", - "releaseHighlightsSeeNotes": "Buong tala ng release", - "highlightCardTechnical": "Teknikal", - }, -} - - -def main() -> None: - for path in sorted(ARB_DIR.glob("app_*.arb")): - locale = path.stem.removeprefix("app_") - insert = NEW_KEYS.get(locale) - if insert is None: - print(f"skip {path.name} (no translations)") - continue - data = json.loads(path.read_text(encoding="utf-8")) - missing = {k: v for k, v in insert.items() if k not in data} - if not missing: - print(f"skip {path.name} (all keys present)") - continue - out = {} - for key, value in data.items(): - out[key] = value - if key == "moreVersionNotes": - for nk, nv in missing.items(): - out[nk] = nv - for nk, nv in missing.items(): - if nk not in out: - out[nk] = nv - path.write_text( - json.dumps(out, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - print(f"updated {path.name}") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/add_service_keys.py b/tool/gen/arb/add_service_keys.py deleted file mode 100644 index efb78e86c..000000000 --- a/tool/gen/arb/add_service_keys.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -"""Insert endpointService* keys after endpointLastSuccessNever in every app_*.arb. - -en-US and the zh variants get proper translations; every other locale starts as -the en-US value (placeholder) so l10n generation succeeds, then a translator -pass replaces them. -""" -import json -import pathlib - -ROOT = pathlib.Path(__file__).resolve().parent.parent / "lib" / "l10n" - -# Order matters: insertion follows this sequence. -SERVICES = [ - "Eew", "Rts", "Radar", "Satellite", "Qpesums", "Wind", "Dpm", "Weather", - "Rain", "Lightning", "Typhoon", "Report", "TremStation", "Event", - "Location", "Notify", "Other", -] - -EN = { - "Eew": "EEW", - "Rts": "RTS", - "Radar": "Radar", - "Satellite": "Satellite", - "Qpesums": "QPE", - "Wind": "Wind", - "Dpm": "Disaster points", - "Weather": "Weather", - "Rain": "Rain", - "Lightning": "Lightning", - "Typhoon": "Typhoon", - "Report": "EQ reports", - "TremStation": "Tremor station", - "Event": "Events", - "Location": "Location", - "Notify": "Notifications", - "Other": "Other", -} - -ZH = { - "Eew": "地震速報", - "Rts": "強震即時警報", - "Radar": "雷達", - "Satellite": "衛星", - "Qpesums": "定量降水", - "Wind": "風場", - "Dpm": "災害點位", - "Weather": "天氣", - "Rain": "降雨", - "Lightning": "閃電", - "Typhoon": "颱風", - "Report": "地震報告", - "TremStation": "震度站", - "Event": "事件", - "Location": "定位", - "Notify": "通知", - "Other": "其他", -} - -# Files written in the zh family (traditional/simplified). -ZH_FILES = {"app_zh.arb", "app_zh_Hans.arb", "app_zh_Hant_HK.arb", "app_zh_TW.arb"} - - -def main() -> None: - for path in sorted(ROOT.glob("app_*.arb")): - data = json.loads(path.read_text(encoding="utf-8")) - anchor = "endpointLastSuccessNever" - if anchor not in data: - print(f"skip {path.name}: missing anchor") - continue - - values = ZH if path.name in ZH_FILES else EN - # Re-run protection: if any service key already exists the file is - # already populated; leave it alone rather than duplicating entries. - if any(k.startswith("endpointService") for k in data): - print(f"skip {path.name}: keys already present") - continue - - entries = [] - for key, value in list(data.items()): - if key == anchor: - entries.append((key, value)) - for k in SERVICES: - entries.append((f"endpointService{k}", values[k])) - else: - entries.append((key, value)) - - out = "{\n" + ",\n".join( - f' "{k}": {json.dumps(v, ensure_ascii=False)}' for k, v in entries - ) + "\n}\n" - path.write_text(out, encoding="utf-8") - print(f"updated {path.name}") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/add_status_page_keys.py b/tool/gen/arb/add_status_page_keys.py deleted file mode 100644 index f508fff65..000000000 --- a/tool/gen/arb/add_status_page_keys.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -"""Insert server-status l10n keys after appLogsNoMatch in every ARB. - -Run from repo root: python3 tool/add_status_page_keys.py -""" - -import json -import pathlib - -ROOT = pathlib.Path(__file__).resolve().parent.parent -ARB_DIR = ROOT / "lib" / "l10n" - -NEW_KEYS = { - "zh": { - "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", - "serverStatusLocal": "本機狀態", - "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", - "serverStatusAllUp": "所有服務正常", - "serverStatusDegraded": "服務效能下降", - "serverStatusDown": "服務異常", - "serverStatusErrorRate": "5xx 錯誤率", - "serverStatusLatency": "平均延遲", - "serverStatusUpdated": "更新於", - }, - "zh_TW": { - "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", - "serverStatusLocal": "本機狀態", - "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", - "serverStatusAllUp": "所有服務正常", - "serverStatusDegraded": "服務效能下降", - "serverStatusDown": "服務異常", - "serverStatusErrorRate": "5xx 錯誤率", - "serverStatusLatency": "平均延遲", - "serverStatusUpdated": "更新於", - }, - "zh_Hant_HK": { - "serverStatusBody": "目前 ExpTech 伺服器的即時健康狀態。", - "serverStatusLocal": "本機狀態", - "serverStatusLocalBody": "伺服器正常不代表警報一定到得了,檢查本機權限與背景執行狀態:", - "serverStatusAllUp": "所有服務正常", - "serverStatusDegraded": "服務效能下降", - "serverStatusDown": "服務異常", - "serverStatusErrorRate": "5xx 錯誤率", - "serverStatusLatency": "平均延遲", - "serverStatusUpdated": "更新於", - }, - "zh_Hans": { - "serverStatusBody": "目前 ExpTech 服务器的实时健康状态。", - "serverStatusLocal": "本机状态", - "serverStatusLocalBody": "服务器正常不代表警报一定到达,请检查本机权限与后台执行状态:", - "serverStatusAllUp": "所有服务正常", - "serverStatusDegraded": "服务性能下降", - "serverStatusDown": "服务异常", - "serverStatusErrorRate": "5xx 错误率", - "serverStatusLatency": "平均延迟", - "serverStatusUpdated": "更新于", - }, - "en": { - "serverStatusBody": "Live health of the ExpTech servers.", - "serverStatusLocal": "Local status", - "serverStatusLocalBody": "A healthy server is not enough — alerts also need your device's permissions and background execution:", - "serverStatusAllUp": "All services operational", - "serverStatusDegraded": "Services degraded", - "serverStatusDown": "Service down", - "serverStatusErrorRate": "5xx error rate", - "serverStatusLatency": "Avg latency", - "serverStatusUpdated": "Updated", - }, - "ja": { - "serverStatusBody": "ExpTech サーバーのリアルタイムの健全性です。", - "serverStatusLocal": "デバイスの状態", - "serverStatusLocalBody": "サーバーが正常でも警報が届くとは限りません。権限とバックグラウンド実行を確認してください:", - "serverStatusAllUp": "すべて正常", - "serverStatusDegraded": "パフォーマンス低下", - "serverStatusDown": "サービス異常", - "serverStatusErrorRate": "5xx エラー率", - "serverStatusLatency": "平均遅延", - "serverStatusUpdated": "更新", - }, - "ko": { - "serverStatusBody": "ExpTech 서버의 실시간 상태입니다.", - "serverStatusLocal": "기기 상태", - "serverStatusLocalBody": "서버가 정상이어도 알림이 도착하지 않을 수 있습니다. 권한과 백그라운드 실행을 확인하세요:", - "serverStatusAllUp": "모든 서비스 정상", - "serverStatusDegraded": "성능 저하", - "serverStatusDown": "서비스 이상", - "serverStatusErrorRate": "5xx 오류율", - "serverStatusLatency": "평균 지연", - "serverStatusUpdated": "업데이트", - }, - "th": { - "serverStatusBody": "สถานะสุขภาพแบบเรียลไทม์ของเซิร์ฟเวอร์ ExpTech", - "serverStatusLocal": "สถานะอุปกรณ์", - "serverStatusLocalBody": "เซิร์ฟเวอร์ปกติไม่ได้แปลว่าการแจ้งเตือนจะถึงเสมอ ตรวจสอบสิทธิ์และการทำงานเบื้องหลัง:", - "serverStatusAllUp": "บริการทั้งหมดปกติ", - "serverStatusDegraded": "ประสิทธิภาพลดลง", - "serverStatusDown": "บริการผิดปกติ", - "serverStatusErrorRate": "อัตราข้อผิดพลาด 5xx", - "serverStatusLatency": "ความหน่วงเฉลี่ย", - "serverStatusUpdated": "อัปเดต", - }, - "vi": { - "serverStatusBody": "Tình trạng thời gian thực của máy chủ ExpTech.", - "serverStatusLocal": "Trạng thái thiết bị", - "serverStatusLocalBody": "Máy chủ bình thường chưa chắc cảnh báo đã đến được. Kiểm tra quyền và chạy nền:", - "serverStatusAllUp": "Tất cả dịch vụ hoạt động", - "serverStatusDegraded": "Hiệu suất giảm", - "serverStatusDown": "Dịch vụ lỗi", - "serverStatusErrorRate": "Tỷ lệ lỗi 5xx", - "serverStatusLatency": "Độ trễ trung bình", - "serverStatusUpdated": "Cập nhật", - }, - "id": { - "serverStatusBody": "Status kesehatan server ExpTech secara real-time.", - "serverStatusLocal": "Status perangkat", - "serverStatusLocalBody": "Server normal belum tentu notifikasi sampai. Periksa izin dan eksekusi latar:", - "serverStatusAllUp": "Semua layanan normal", - "serverStatusDegraded": "Kinerja menurun", - "serverStatusDown": "Layanan bermasalah", - "serverStatusErrorRate": "Tingkat error 5xx", - "serverStatusLatency": "Latensi rata-rata", - "serverStatusUpdated": "Diperbarui", - }, - "fil": { - "serverStatusBody": "Real-time na kalusugan ng mga server ng ExpTech.", - "serverStatusLocal": "Katayuan ng device", - "serverStatusLocalBody": "Hindi sapat na normal ang server — kailangan ding gumana ang mga pahintulot at background execution:", - "serverStatusAllUp": "Lahat ng serbisyo ay normal", - "serverStatusDegraded": "Bumaba ang pagganap", - "serverStatusDown": "May problema ang serbisyo", - "serverStatusErrorRate": "Rate ng error na 5xx", - "serverStatusLatency": "Karaniwang latency", - "serverStatusUpdated": "Na-update", - }, -} - - -def main() -> None: - for path in sorted(ARB_DIR.glob("app_*.arb")): - locale = path.stem.removeprefix("app_") - insert = NEW_KEYS.get(locale) - if insert is None: - print(f"skip {path.name} (no translations)") - continue - data = json.loads(path.read_text(encoding="utf-8")) - if "serverStatusBody" in data: - print(f"skip {path.name} (already present)") - continue - out = {} - for key, value in data.items(): - out[key] = value - if key == "appLogsNoMatch": - out.update(insert) - path.write_text( - json.dumps(out, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - print(f"updated {path.name}") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/add_status_web_keys.py b/tool/gen/arb/add_status_web_keys.py deleted file mode 100644 index e7c917bde..000000000 --- a/tool/gen/arb/add_status_web_keys.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env python3 -"""Insert the server-status page's new keys in every ARB. - -Adds: - serverStatusWeb — full-width link to the web dashboard - serverStatusWebUrl — subtitle (the web URL) - endpointTierLbApi / endpointTierCoreApi / endpointTierCoreExclusiveApi / - endpointTierCoreStatic / endpointTierLegacyApi — tier section headers - -Run from repo root: python3 tool/add_status_web_keys.py -""" - -import json -import pathlib - -ROOT = pathlib.Path(__file__).resolve().parent.parent -ARB_DIR = ROOT / "lib" / "l10n" - -LOCALES = { - "zh": { - "serverStatusWeb": "在瀏覽器中開啟完整狀態頁", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API(EEW / 強震警報)", - "endpointTierLbStatic": "LB 靜態資源", - "endpointTierCoreApi": "Core API(地震報告)", - "endpointTierCoreStatic": "Core 靜態資源", - "endpointTierCoreExclusiveApi": "Core 專屬 API(雷達 / 氣象 / 風場)", - "endpointTierCoreStaticExclusive": "Core 專屬靜態資源", - "endpointTierLegacyApi": "舊版 API(api-1)", - }, - "zh_TW": { - "serverStatusWeb": "在瀏覽器中開啟完整狀態頁", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API(EEW / 強震警報)", - "endpointTierLbStatic": "LB 靜態資源", - "endpointTierCoreApi": "Core API(地震報告)", - "endpointTierCoreStatic": "Core 靜態資源", - "endpointTierCoreExclusiveApi": "Core 專屬 API(雷達 / 氣象 / 風場)", - "endpointTierCoreStaticExclusive": "Core 專屬靜態資源", - "endpointTierLegacyApi": "舊版 API(api-1)", - }, - "zh_Hant_HK": { - "serverStatusWeb": "在瀏覽器中開啟完整狀態頁", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API(EEW / 強震警報)", - "endpointTierLbStatic": "LB 靜態資源", - "endpointTierCoreApi": "Core API(地震報告)", - "endpointTierCoreStatic": "Core 靜態資源", - "endpointTierCoreExclusiveApi": "Core 專屬 API(雷達 / 氣象 / 風場)", - "endpointTierCoreStaticExclusive": "Core 專屬靜態資源", - "endpointTierLegacyApi": "舊版 API(api-1)", - }, - "zh_Hans": { - "serverStatusWeb": "在浏览器中打开完整状态页", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API(EEW / 强震警报)", - "endpointTierLbStatic": "LB 静态资源", - "endpointTierCoreApi": "Core API(地震报告)", - "endpointTierCoreStatic": "Core 静态资源", - "endpointTierCoreExclusiveApi": "Core 专属 API(雷达 / 气象 / 风场)", - "endpointTierCoreStaticExclusive": "Core 专属静态资源", - "endpointTierLegacyApi": "旧版 API(api-1)", - }, - "en": { - "serverStatusWeb": "Open the full status page in your browser", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API (EEW / strong-motion alerts)", - "endpointTierLbStatic": "LB static", - "endpointTierCoreApi": "Core API (earthquake reports)", - "endpointTierCoreStatic": "Core static", - "endpointTierCoreExclusiveApi": "Core-exclusive API (radar / weather / wind)", - "endpointTierCoreStaticExclusive": "Core-exclusive static", - "endpointTierLegacyApi": "Legacy API (api-1)", - }, - "ja": { - "serverStatusWeb": "完全なステータスページをブラウザで開く", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API(EEW / 強震警報)", - "endpointTierLbStatic": "LB 静的リソース", - "endpointTierCoreApi": "Core API(地震報告)", - "endpointTierCoreStatic": "Core 静的リソース", - "endpointTierCoreExclusiveApi": "Core 専用 API(レーダー / 気象 / 風)", - "endpointTierCoreStaticExclusive": "Core 専用静的リソース", - "endpointTierLegacyApi": "レガシー API(api-1)", - }, - "ko": { - "serverStatusWeb": "브라우저에서 전체 상태 페이지 열기", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API (EEW / 강진 경보)", - "endpointTierLbStatic": "LB 정적 리소스", - "endpointTierCoreApi": "Core API (지진 보고)", - "endpointTierCoreStatic": "Core 정적 리소스", - "endpointTierCoreExclusiveApi": "Core 전용 API (레이다 / 기상 / 바람)", - "endpointTierCoreStaticExclusive": "Core 전용 정적 리소스", - "endpointTierLegacyApi": "레거시 API (api-1)", - }, - "th": { - "serverStatusWeb": "เปิดหน้าสถานะเต็มในเบราว์เซอร์", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API (EEW / แจ้งเตือนแผ่นดินไหวรุนแรง)", - "endpointTierLbStatic": "LB ทรัพยากรคงที่", - "endpointTierCoreApi": "Core API (รายงานแผ่นดินไหว)", - "endpointTierCoreStatic": "Core ทรัพยากรคงที่", - "endpointTierCoreExclusiveApi": "Core เฉพาะ API (เรดาร์ / อากาศ / ลม)", - "endpointTierCoreStaticExclusive": "Core เฉพาะทรัพยากรคงที่", - "endpointTierLegacyApi": "API เดิม (api-1)", - }, - "vi": { - "serverStatusWeb": "Mở trang trạng thái đầy đủ trong trình duyệt", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API (EEW / cảnh báo rung lắc mạnh)", - "endpointTierLbStatic": "LB tĩnh", - "endpointTierCoreApi": "Core API (báo cáo động đất)", - "endpointTierCoreStatic": "Core tĩnh", - "endpointTierCoreExclusiveApi": "Core độc quyền API (radar / thời tiết / gió)", - "endpointTierCoreStaticExclusive": "Core độc quyền tĩnh", - "endpointTierLegacyApi": "API kế thừa (api-1)", - }, - "id": { - "serverStatusWeb": "Buka halaman status lengkap di browser", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API (EEW / peringatan gempa kuat)", - "endpointTierLbStatic": "LB statis", - "endpointTierCoreApi": "Core API (laporan gempa)", - "endpointTierCoreStatic": "Core statis", - "endpointTierCoreExclusiveApi": "Core eksklusif API (radar / cuaca / angin)", - "endpointTierCoreStaticExclusive": "Core eksklusif statis", - "endpointTierLegacyApi": "API lama (api-1)", - }, - "fil": { - "serverStatusWeb": "Buksan ang buong status page sa browser", - "serverStatusWebUrl": "status.exptech.dev", - "endpointTierLbApi": "LB API (EEW / malalakas na alerto sa pagyanig)", - "endpointTierLbStatic": "LB static", - "endpointTierCoreApi": "Core API (ulat ng lindol)", - "endpointTierCoreStatic": "Core static", - "endpointTierCoreExclusiveApi": "Core-eksklusibong API (radar / panahon / hangin)", - "endpointTierCoreStaticExclusive": "Core-eksklusibong static", - "endpointTierLegacyApi": "Legacy API (api-1)", - }, -} - -ANCHOR = "serverStatusUpdated" - - -def main() -> None: - for path in sorted(ARB_DIR.glob("app_*.arb")): - locale = path.stem.removeprefix("app_") - if locale not in LOCALES: - print(f"skip {path.name} (no translations)") - continue - updates = LOCALES[locale] - data = json.loads(path.read_text(encoding="utf-8")) - if "serverStatusWeb" in data: - print(f"skip {path.name} (already present)") - continue - out = {} - for key, value in data.items(): - out[key] = value - if key == ANCHOR: - out.update(updates) - path.write_text( - json.dumps(out, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - print(f"updated {path.name}") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/tighten_status_web_label.py b/tool/gen/arb/tighten_status_web_label.py deleted file mode 100644 index 1c6e8dc67..000000000 --- a/tool/gen/arb/tighten_status_web_label.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -"""Tighten the server-status page's web-dashboard button to just its name. - -The full-width card is the entry to the web dashboard; its title reads the -same as the page's own name instead of a wordy prompt. The subtitle still -shows the host so the row stays a link, not a duplicate header. -""" - -import json -import pathlib - -ROOT = pathlib.Path(__file__).resolve().parent.parent -ARB_DIR = ROOT / "lib" / "l10n" - -LOCALES = { - "zh": "伺服器狀態", - "zh_TW": "伺服器狀態", - "zh_Hant_HK": "伺服器狀態", - "zh_Hans": "服务器状态", - "en": "Server status", - "ja": "サーバー状態", - "ko": "서버 상태", - "th": "สถานะเซิร์ฟเวอร์", - "vi": "Trạng thái máy chủ", - "id": "Status server", - "fil": "Katayuan ng server", -} - - -def main() -> None: - for path in sorted(ARB_DIR.glob("app_*.arb")): - locale = path.stem.removeprefix("app_") - label = LOCALES.get(locale) - if label is None: - continue - data = json.loads(path.read_text(encoding="utf-8")) - if "serverStatusWeb" not in data: - print(f"skip {path.name} (missing key)") - continue - data["serverStatusWeb"] = label - path.write_text( - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - print(f"updated {path.name}") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tool/gen/arb/json_to_dart_highlights.py b/tool/gen/release_highlights.py similarity index 94% rename from tool/gen/arb/json_to_dart_highlights.py rename to tool/gen/release_highlights.py index 0b2e4e87d..4101fb41a 100644 --- a/tool/gen/arb/json_to_dart_highlights.py +++ b/tool/gen/release_highlights.py @@ -8,21 +8,21 @@ imports (`package:dpip_release_highlights//...`). Each version keeps its own Dart; the app imports only the current one. -Usage: python3 tool/gen/arb/json_to_dart_highlights.py [VERSION ...] +Usage: python3 tool/gen/release_highlights.py [VERSION ...] (default: every version directory under release_highlights/assets/) """ import json import pathlib import sys -ROOT = pathlib.Path(__file__).resolve().parents[3] +ROOT = pathlib.Path(__file__).resolve().parents[2] CONTENT = ROOT / "release_highlights" ASSETS = CONTENT / "assets" KINDS = ("normal", "advanced") HEADER = """// Version-highlight card content for DPIP {version} ({kind}). // -// GENERATED from `{archived}` by `tool/gen/arb/json_to_dart_highlights.py` — edit the +// GENERATED from `{archived}` by `tool/gen/release_highlights.py` — edit the // JSON, not this file. Rendering lives in `lib/features/release_highlights`; // this package carries only data. library;